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/<id> 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 <[email protected]>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
findAuthor,
|
||||
mediaFromAttachments,
|
||||
mediaFromNode,
|
||||
partsOfPost,
|
||||
postIdFrom,
|
||||
scrapeParts,
|
||||
} from '../src/platforms/facebook.ts';
|
||||
import { fixture } from './helpers.ts';
|
||||
|
||||
// Real captures of what a logged-out Facebook page ships, trimmed of the DASH
|
||||
// manifests and tracking blobs the adapter never reads. Each is a different
|
||||
// shape: a reel arrives with the next reels of the feed attached, a photo post
|
||||
// keys itself by an opaque `pfbid` with no id in the address at all, and a
|
||||
// video post has its pieces scattered over four separate payload blocks.
|
||||
const REEL = 'https://www.facebook.com/reel/962551420197596/';
|
||||
const PHOTO_POST =
|
||||
'https://www.facebook.com/NASA/posts/pfbid02yzKA4Z5Wnep5xU3PLWyWgRuVebWg3UZoATsEH3wuZcKoZuiLSBL8rEkbe4qTZ65Jl';
|
||||
const VIDEO_POST =
|
||||
'https://www.facebook.com/NASA/videos/nancy-grace-roman-space-telescope-launch-trailer/1082585924135116/';
|
||||
|
||||
const reel = () => fixture<unknown[]>('facebook/reel.json');
|
||||
const photoPost = () => fixture<unknown[]>('facebook/photo-post.json');
|
||||
const videoPost = () => fixture<unknown[]>('facebook/video-post.json');
|
||||
|
||||
const scrape = (payloads: unknown[], id: string | undefined, url: string) =>
|
||||
scrapeParts(partsOfPost(payloads, id, url));
|
||||
|
||||
test('a reel is read as the reel that was linked, not the feed around it', () => {
|
||||
const post = scrape(reel(), '962551420197596', REEL);
|
||||
|
||||
assert.equal(post.author?.name, 'SpeechProf');
|
||||
assert.equal(post.text, 'I need answers.');
|
||||
assert.equal(post.media.length, 1);
|
||||
assert.equal(post.media[0]?.kind, 'video');
|
||||
});
|
||||
|
||||
test('the reels Facebook staples on are really in the payload', () => {
|
||||
// Without this the test above proves nothing: the whole point is that the
|
||||
// page carries other people's reels in the same shape as the linked one.
|
||||
const others = JSON.stringify(reel()).match(/facebook\.com\\?\/reel\\?\/(\d+)/g) ?? [];
|
||||
const ids = new Set(others.map((match) => /(\d+)/.exec(match)?.[1]));
|
||||
ids.delete('962551420197596');
|
||||
assert.ok(ids.size > 0, 'the fixture should carry recommended reels too');
|
||||
});
|
||||
|
||||
test('a portrait reel keeps its shape, its poster and its length', () => {
|
||||
const item = scrape(reel(), '962551420197596', REEL).media[0];
|
||||
|
||||
assert.equal(item?.width, 720);
|
||||
assert.equal(item?.height, 1280);
|
||||
assert.equal(item?.kind === 'video' ? item.durationSec : undefined, 15);
|
||||
assert.match(
|
||||
item?.kind === 'video' ? (item.poster?.url ?? '') : '',
|
||||
/scontent-.*fbcdn\.net/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a post whose address carries no id is found by its permalink', () => {
|
||||
const post = scrape(photoPost(), undefined, PHOTO_POST);
|
||||
|
||||
assert.match(post.author?.name ?? '', /^NASA/);
|
||||
assert.match(post.text ?? '', /^25 years ago/);
|
||||
assert.equal(post.media[0]?.kind, 'image');
|
||||
});
|
||||
|
||||
test('the client configuration shipped alongside the post is not read as the post', () => {
|
||||
// With neither an id nor a matching permalink there is nothing to anchor
|
||||
// on, and the page's own configuration — thousands of nodes carrying a
|
||||
// `name` — is what a plain search finds first.
|
||||
const post = scrape(photoPost(), undefined, 'https://www.facebook.com/somewhere/else');
|
||||
|
||||
assert.match(post.author?.name ?? '', /^NASA/);
|
||||
});
|
||||
|
||||
test('a video post is assembled from the several blocks it is split over', () => {
|
||||
const post = scrape(videoPost(), '1082585924135116', VIDEO_POST);
|
||||
|
||||
// The name, the vanity address and the picture arrive in different blocks
|
||||
// from the file and the timestamp; all of it has to end up on one post.
|
||||
assert.match(post.author?.name ?? '', /^NASA/);
|
||||
assert.equal(post.author?.url, 'https://www.facebook.com/NASA');
|
||||
assert.ok(post.author?.profile_picture?.uri, 'the avatar is in a block of its own');
|
||||
assert.match(post.text ?? '', /Nancy Grace Roman/);
|
||||
assert.equal(post.postedAt, '2026-08-28T23:23:27.000Z');
|
||||
assert.equal(post.media[0]?.kind, 'video');
|
||||
});
|
||||
|
||||
test('someone commenting under the post is not mistaken for its author', () => {
|
||||
const payloads = videoPost();
|
||||
assert.ok(
|
||||
JSON.stringify(payloads).includes('Michael Hall'),
|
||||
'the fixture should carry the comments, which is what makes this a trap',
|
||||
);
|
||||
|
||||
const author = findAuthor(partsOfPost(payloads, '1082585924135116', VIDEO_POST));
|
||||
assert.match(author?.name ?? '', /^NASA/);
|
||||
});
|
||||
|
||||
test('a page that answers to a different post is not read at all', () => {
|
||||
// Facebook serves a link to something it no longer has by quietly handing
|
||||
// back something else — `/watch/<id>` for a video that is gone comes back
|
||||
// as the Watch home page, feed and all. A page that names neither the id
|
||||
// nor the address is a failure rather than whatever happened to be on it.
|
||||
assert.deepEqual(
|
||||
partsOfPost(reel(), '111111111111111', 'https://www.facebook.com/watch/111111111111111/'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('an address with no id in it still falls back to the query results', () => {
|
||||
// The strictness above only applies where there was an id to check: a
|
||||
// `pfbid` permalink has none, and refusing those would refuse every post
|
||||
// shared from a page.
|
||||
assert.notDeepEqual(partsOfPost(photoPost(), undefined, 'https://www.facebook.com/elsewhere'), []);
|
||||
});
|
||||
|
||||
test('the id is taken from wherever the address keeps it', () => {
|
||||
const id = (url: string) => postIdFrom(new URL(url));
|
||||
|
||||
assert.equal(id('https://www.facebook.com/reel/962551420197596/'), '962551420197596');
|
||||
assert.equal(id('https://www.facebook.com/photo/?fbid=1626865842142119&set=a.41'), '1626865842142119');
|
||||
assert.equal(id('https://www.facebook.com/watch/?v=1082585924135116'), '1082585924135116');
|
||||
assert.equal(id('https://www.facebook.com/NASA/videos/some-slug/1082585924135116/'), '1082585924135116');
|
||||
assert.equal(id('https://www.facebook.com/groups/123456789/posts/987654321/'), '987654321');
|
||||
assert.equal(id('https://www.facebook.com/NASA/posts/pfbid02yzKA4Z5'), undefined);
|
||||
});
|
||||
|
||||
test('`id` on a permalink is the page, not the post, so it is left alone', () => {
|
||||
assert.equal(
|
||||
postIdFrom(new URL('https://www.facebook.com/permalink.php?story_fbid=222222222&id=999999999')),
|
||||
'222222222',
|
||||
);
|
||||
assert.equal(postIdFrom(new URL('https://www.facebook.com/profile.php?id=999999999')), undefined);
|
||||
});
|
||||
|
||||
test('the better of the two files Facebook offers is the one used', () => {
|
||||
const media = mediaFromNode({
|
||||
videoDeliveryLegacyFields: {
|
||||
browser_native_sd_url: 'https://video.example/sd.mp4',
|
||||
browser_native_hd_url: 'https://video.example/hd.mp4',
|
||||
},
|
||||
preferred_thumbnail: { image: { uri: 'https://image.example/poster.jpg' } },
|
||||
});
|
||||
|
||||
assert.equal(media[0]?.url, 'https://video.example/hd.mp4');
|
||||
assert.equal(media[0]?.kind === 'video' ? media[0].poster?.url : undefined,
|
||||
'https://image.example/poster.jpg');
|
||||
});
|
||||
|
||||
test('an empty `all_subattachments` is a single picture, not a carousel', () => {
|
||||
// Facebook ships the key on every post, so its presence says nothing.
|
||||
const media = mediaFromAttachments([
|
||||
{
|
||||
all_subattachments: { nodes: [] },
|
||||
styles: { attachment: { media: { photo_image: { uri: 'https://image.example/one.jpg' } } } },
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(media.map((item) => item.url), ['https://image.example/one.jpg']);
|
||||
});
|
||||
|
||||
test('a populated `all_subattachments` is every picture in the post', () => {
|
||||
const media = mediaFromAttachments([
|
||||
{
|
||||
media: { photo_image: { uri: 'https://image.example/cover.jpg' } },
|
||||
all_subattachments: {
|
||||
nodes: [
|
||||
{ media: { photo_image: { uri: 'https://image.example/1.jpg' } } },
|
||||
{ media: { photo_image: { uri: 'https://image.example/2.jpg', width: 8, height: 6 } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(media.map((item) => item.url), [
|
||||
'https://image.example/1.jpg',
|
||||
'https://image.example/2.jpg',
|
||||
]);
|
||||
assert.equal(media[1]?.width, 8);
|
||||
});
|
||||
|
||||
test('every asset carries the headers the Facebook CDN is given', () => {
|
||||
const post = scrape(reel(), '962551420197596', REEL);
|
||||
const item = post.media[0];
|
||||
|
||||
assert.equal(item?.fetchHeaders?.['Referer'], 'https://www.facebook.com/');
|
||||
assert.equal(item?.fetchHeaders?.['Origin'], 'https://www.facebook.com');
|
||||
});
|
||||
Reference in New Issue
Block a user