2 Commits
Author SHA1 Message Date
KydoimosandClaude Opus 5 51dfade4f9 Sign the automated version bump commit
CI / Typecheck, test, build (pull_request) Successful in 38s
Publish / Build and push (pull_request) Successful in 1m48s
Publish / Move the working version on (pull_request) Skipped
main requires signed commits, so the pull request the bump job opens after a
release cannot be merged while the commit in it is unsigned. The key the server
signs merge commits with is not reachable from a runner, so the job signs with a
dedicated release-bot SSH key that the instance trusts through
TRUSTED_SSH_KEYS — no bot account, because an account key is only consulted
after the web Verify flow and that flow has no API.

Inert until the key is trusted and RELEASE_BOT_SIGNING_KEY is set, and loudly so:
the step checks the secret and ssh-keygen before it starts, runs the key through
ssh-keygen -y so a truncated or re-wrapped one is caught as itself rather than as
"gpg failed to sign the data", and the commit is re-read for a gpgsig header
before it is pushed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 20:30:09 -03:00
thatguygriff 6e687cdddc Merge pull request 'Set the working version to 1.2.5' (#15) from release/bump-1.2.5 into main
CI / Typecheck, test, build (push) Successful in 17s
Publish / Build and push (push) Successful in 35s
Publish / Move the working version on (push) Skipped
Reviewed-on: #15
2026-09-03 00:58:52 +00:00
21 changed files with 49 additions and 8899 deletions
-58
View File
@@ -1,58 +0,0 @@
# AGENTS.md
Self-hosted page that renders a social post (X, Threads, Instagram, Facebook, TikTok, Bluesky, Reddit) without the app, by driving real headless Chromium logged-out. No auth; private-network use only. Keep deployment specifics (hosts, clusters, registries, manifests) out of this repo.
`AGENTS.md` (this file) is the source of truth for agent instructions. `CLAUDE.md` is supplemental detail (adapter internals, per-platform quirks, publishing/signing). `README.md` is user-facing. On conflict, this file wins; executable sources win over all prose.
## Commands
```sh
npm ci
npx playwright install chromium # once
npm run dev # http://localhost:8080, runs src directly via type-stripping
npm run resolve -- '<post url>' # ground truth: real browser, prints Post JSON or error
npm run probe -- '<adapter url>' [waitMs] # what the page actually served (payload keys, DOM video/img, OG, challenge, filtered API traffic)
npm test # node:test vs test/fixtures/, no network
npm run typecheck # this IS the lint step — no ESLint
npm run build # tsc -> dist/
```
CI (`.gitea/workflows/ci.yml`) runs `typecheck -> test -> build` with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` (fixtures need no browser).
## "This link didn't work" workflow — do not skip step 1
1. `npm run resolve -- '<url>'` — ground truth.
2. Classify before fixing: transient rate-limit (esp. TikTok — wait and retry) vs verification puzzle (expect redirect to `/challenge/<id>`) vs structured-payload miss (carousel count wrong / poster where video belongs = DOM fall-through) vs Facebook wrong-post (feed read instead of post — must become an error) vs Reddit post-ok-no-comments (`.json` refused, page fallback).
3. `npm run probe -- '<url the adapter loads>'` — e.g. Instagram `/embed/captioned/`, not the post URL. Every adapter fix starts here.
4. Fix adapter + capture a real-payload fixture in `test/fixtures/` + assert in `test/<platform>.test.ts`. Fix without fixture regresses.
5. `npm test`, then re-resolve end to end.
## Architecture
Request → `src/routes/post.ts``src/platforms/index.ts` (prefix → adapter) → `src/resolve.ts` (cache/timeout/concurrency) → `withPage` (`src/browser/pool.ts`) → adapter → `Post` (`src/types.ts`) → `src/render/post.ts`.
Adding a platform: one file in `src/platforms/` + one row in `index.ts`. Nothing downstream changes.
## Rules agents get wrong
- Extraction order is load-bearing: in-flight API response → inline payload → rendered DOM → OG tags. Keep it.
- `Post` is `Segment[]`, not one body. Bluesky/Threads emit author chains; `isAnchor` marks the linked post. `Segment.quoted` (X, Bluesky) keeps quoted author+text+media together — never lift quoted media into the quoter's media (false attribution).
- `Post.comments` tree is Reddit-only. Renderer uses `<details>` per comment; nesting is what makes folding free.
- Media is never linked to a CDN: register in `src/media/registry.ts`, serve `/m/<id>` with stored `Referer`/`Cookie`, forward `Range`. Exception: `direct: true` HLS (no playlist rewriting).
- One Chromium, one persistent context — cookies/banners accumulate deliberately. Images/video/fonts are aborted at route layer (URLs/`src` attrs survive for DOM extraction).
- `src/platforms/scan.ts` pulls balanced JSON by key, including escaped-inside-JS-string (Instagram). Payload present-but-empty usually means one more encoding level.
- Facebook: narrow to the linked post via `partsOfPost` (id, or `permalink_url`/`wwwURL` for `pfbid`; unmatched id = failure, never read the shipped-along feed). Collect all claiming nodes (caption/author/files live in different blocks); post owner comes from `actors`/`owner`/`video_owner`/`owner_as_page`, never a bare `author` (that's a commenter).
- X: decode pre-escaped text after slicing `display_text_range` (UTF-16 units into escaped text); expand `t.co` by matching shortlink text in `entities.urls`, not `indices`; rebuild quoted URL from handle + `id_str`.
- Reddit: prefer `.json` (post + first comment page); cold profile gets a JS challenge — navigate once, retry, keep the cookie. `replies` is `""` when empty. Video with sound (`has_audio`) must use `hls_url` direct; silent uses proxied MP4; poster is `preview.images`, never `scrubber_media_url`. Gallery order comes only from `gallery_data`. Comment images are tokens resolved via the comment's own `media_metadata`, with Giphy-id → `i.giphy.com/media/<id>/giphy.gif` fallback; render Markdown via `src/render/markdown.ts` (escape-first), never `body_html`.
- TikTok: find whichever `__UNIVERSAL_DATA_FOR_REHYDRATION__` scope holds `itemStruct` (key varies); CDN needs `Referer` + cookies; bare-path `vm.`/`vt.` codes rebuilt in `buildOriginalUrl`.
- Threads/Instagram share the media schema (`meta-media.ts`); Threads follow-up = author replying to self (vs stranger reply with same `reply_to_author`); Bluesky app calls `getPostThreadV2`, page fallback matches V1 only.
- Never a bare error page: failed resolves render a card with platform + original URL + copy button.
- Video sizing comes from the poster (empty SVG stand-in when missing); iOS audio needs the `playback` session in `public/app.js`.
## Conventions
- TS 7, ESM, `.ts` import specifiers (tsc rewrites to `.js`); `node --experimental-strip-types` runs `src`/`test`/`bin` directly. `strict` + `noUnusedLocals` + `noUncheckedIndexedAccess` enforced by `typecheck`.
- `public/` is served as-is: plain JS, not TS. `public/browsers.js` stays a separate module so tests can import it (`allowJs`).
- Tests: `node:test`, fixture(payload) via `test/helpers.ts`, no network. Single-file run: `node --test --experimental-strip-types test/reddit.test.ts`.
- All post text is untrusted: emit via `html` tagged template / `linkify` only, never string-concatenated markup. Comments explain *why*, not what the next line does.
- Config is env-only with working defaults (`src/config.ts`); `PROFILE_DIR` persists browser state. Chromium needs >64Mi `/dev/shm`.
+1 -39
View File
@@ -1,12 +1,7 @@
# antisocial — working notes # antisocial — working notes
> `AGENTS.md` is the source of truth for agent instructions. This file is
> supplemental detail (adapter internals, per-platform quirks,
> publishing/signing). On conflict, `AGENTS.md` wins; executable sources win
> over all prose.
A self-hosted page that shows a social post without the app. StopTheMadness rewrites A self-hosted page that shows a social post without the app. StopTheMadness rewrites
links to X, Threads, Instagram, Facebook, TikTok, Bluesky and Reddit into links to X, Threads, Instagram, TikTok, Bluesky and Reddit into
`/<prefix>/<original path>`; this resolves the post by driving a real headless Chromium `/<prefix>/<original path>`; 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. and renders the media, the text, a platform badge and a copy-the-original button.
@@ -40,7 +35,6 @@ 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. | | 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 `<video>` had not hydrated when the DOM was read. | | Poster image where a video belongs | Same fall-through, plus the `<video>` had not hydrated when the DOM was read. |
| Nothing at all | The platform refused this specific post logged out, or a key moved. | | Nothing at all | The platform refused this specific post logged out, or a key moved. |
| Someone else's post entirely | Facebook. The linked post was not on the page, so the feed it ships alongside got read instead. Should be an error, not a post — check what `partsOfPost` matched. |
| Reddit post fine, no comments | The `.json` was refused and it fell through to the page, where the comment tree loads late. | | Reddit post fine, no comments | The `.json` was refused and it fell through to the page, where the comment tree loads late. |
**3. Look at what the page actually served.** **3. Look at what the page actually served.**
@@ -95,9 +89,6 @@ Things worth knowing before editing:
- **Extraction is layered**, most structured first: the platform's own API response - **Extraction is layered**, most structured first: the platform's own API response
caught in flight → an inline payload → the rendered DOM → OG tags. Keep that order caught in flight → an inline payload → the rendered DOM → OG tags. Keep that order
when you touch an adapter; each layer is the fallback for the one above. when you touch an adapter; each layer is the fallback for the one above.
- **`mediaFromOpenGraph` in `meta-media.ts`** is the floor shared by Instagram, Threads
and Facebook. The three are one product underneath; the first two also share the media
schema, Facebook has its own.
- **`src/platforms/scan.ts`** pulls a balanced JSON object out of a page by key, - **`src/platforms/scan.ts`** pulls a balanced JSON object out of a page by key,
including when it arrives escaped inside a JS string. Instagram needs this. If a including when it arrives escaped inside a JS string. Instagram needs this. If a
payload looks present but parses to nothing, suspect an extra encoding level. payload looks present but parses to nothing, suspect an extra encoding level.
@@ -181,35 +172,6 @@ Things worth knowing before editing:
typed without a scheme counts as well, but only when it ends in an image extension — typed without a scheme counts as well, but only when it ends in an image extension —
the rule wants a host, a path *and* that extension, because comments are full of the rule wants a host, a path *and* that extension, because comments are full of
dotted, slashed prose that must not turn into links. dotted, slashed prose that must not turn into links.
- **Facebook** — the post page logged out carries everything: the caption, the author,
the files and the dimensions, in `ScheduledServerJS` payloads that are plain JSON in
ordinary `<script>` tags. What it does *not* carry is only that post. A reel ships the
next five reels of the feed under `viewer.lasso_blue_feed`, a video ships its related
videos, and each one has the same fields in the same shape as the real one — so
nothing may be read until the post has been picked out, which `partsOfPost` does by
matching 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`/`wwwURL` instead; only
then, 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 too, 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*: Facebook answers a link to something it no longer has by
quietly serving something else (`/watch/<id>` becomes the Watch home page, feed and
all), and reading that would put a stranger's video under the link that was shared.
One post's pieces are spread over several payload blocks — a video post keeps its
files in one, its caption in another and its author's avatar in a third — so every
claiming node is collected, not just the first. 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 in the same shape with a name
and a picture, and taking the first node with a name on it finds a commenter as
readily as the poster. Media: `videoDeliveryLegacyFields.browser_native_hd_url` for a
video with `preferred_thumbnail` as its poster, `photo_image`/`image` for a picture,
and `all_subattachments.nodes` for a post of several — which is shipped empty on
single-picture posts, so only a populated one is a carousel. The CDN is signed and
serves `Range` without being asked for a referrer, but the assets are proxied like
everything else. `/share/{r,v,p,g}/<code>` links are stubs; the adapter follows one
and hands back where it landed, since the share code says nothing about what it opens.
`m.facebook.com` is a login wall logged out — always rebuild on `www.`.
- **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its - **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its
payloads are full of empty stub nodes, so the finder only accepts a node with actual payloads are full of empty stub nodes, so the finder only accepts a node with actual
candidates in it. The page ships the linked post, the author's follow-ups, other candidates in it. The page ships the linked post, the author's follow-ups, other
+6 -22
View File
@@ -2,12 +2,11 @@
Reads social posts back to you without the app. Reads social posts back to you without the app.
Links to X, Threads, Instagram, Facebook, TikTok, Bluesky and Reddit get shared Links to X, Threads, Instagram, TikTok, Bluesky and Reddit get shared constantly, and
constantly, and opening one means an app interstitial, a login wall, a feed you didn't opening one means an app interstitial, a login wall, a feed you didn't ask for, and a
ask for, and a pile of tracking. antisocial is the other half of a StopTheMadness rewrite pile of tracking. antisocial is the other half of a StopTheMadness rewrite rule: the link gets
rule: the link gets redirected here, and you get the post — the media and the words — redirected here, and you get the post — the media and the words — plus a badge saying
plus a badge saying where it came from and a button to copy the original URL if you do where it came from and a button to copy the original URL if you do want to go there.
want to go there.
Built for one person, on a private network. There is **no authentication of any kind** Built for one person, on a private network. There is **no authentication of any kind**
anything that can reach it can drive a browser through it, so put it somewhere only you anything that can reach it can drive a browser through it, so put it somewhere only you
@@ -38,10 +37,6 @@ https://antisocial.example.com/threads/$1
/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/ /^https:\/\/(?:www\.)?instagram\.com\/(.*)$/
https://antisocial.example.com/ig/$1 https://antisocial.example.com/ig/$1
# Facebook
/^https:\/\/(?:www\.|m\.|web\.)?facebook\.com\/(.*)$/
https://antisocial.example.com/fb/$1
# TikTok # TikTok
/^https:\/\/(?:www\.|vm\.|vt\.)?tiktok\.com\/(.*)$/ /^https:\/\/(?:www\.|vm\.|vt\.)?tiktok\.com\/(.*)$/
https://antisocial.example.com/tiktok/$1 https://antisocial.example.com/tiktok/$1
@@ -73,9 +68,7 @@ share codes lose their subdomain in the rewrite; a single opaque path segment is
recognised as a share code and rebuilt as `vm.tiktok.com/<code>/`, or `redd.it/<code>` recognised as a share code and rebuilt as `vm.tiktok.com/<code>/`, or `redd.it/<code>`
where it came from Reddit. A Reddit `/r/<sub>/s/<code>` share link is followed to the where it came from Reddit. A Reddit `/r/<sub>/s/<code>` share link is followed to the
post it points at, and that permalink — not the opaque share code — is what the copy post it points at, and that permalink — not the opaque share code — is what the copy
button hands back. A Facebook `/share/r/<code>` link is followed the same way, and button hands back.
`m.facebook.com` is rebuilt on `www.` because the mobile site answers a logged-out
reader with a login page and nothing else.
`/` serves these rules with the live hostname already filled in, if you'd rather copy `/` serves these rules with the live hostname already filled in, if you'd rather copy
them from there. them from there.
@@ -129,7 +122,6 @@ Each adapter layers its extraction, most structured first:
| Bluesky | the public AT Protocol API | `getPostThread`; falls back to the post page | | Bluesky | the public AT Protocol API | `getPostThread`; falls back to the post page |
| X | `platform.twitter.com` embed | the `cdn.syndication.twimg.com/tweet-result` response | | X | `platform.twitter.com` embed | the `cdn.syndication.twimg.com/tweet-result` response |
| Instagram | `/embed/captioned/` | `shortcode_media`, then the rendered `<video>`/`<img>` | | Instagram | `/embed/captioned/` | `shortcode_media`, then the rendered `<video>`/`<img>` |
| Facebook | the post page | the Relay payloads, narrowed to the nodes naming that post |
| TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` | | TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
| Threads | the post page | the Relay payloads in `<script type="application/json">` | | Threads | the post page | the Relay payloads in `<script type="application/json">` |
| Reddit | the post's own `.json` | the post and the first page of comments; falls back to the page | | Reddit | the post's own `.json` | the post and the first page of comments; falls back to the page |
@@ -214,11 +206,6 @@ Known rough edges:
- **Instagram is the least reliable.** It only ships the structured payload some of the - **Instagram is the least reliable.** It only ships the structured payload some of the
time. When it doesn't, the rendered DOM carries single images and reels fine, but a time. When it doesn't, the rendered DOM carries single images and reels fine, but a
carousel will come back as its first image only. carousel will come back as its first image only.
- **Facebook ships the post surrounded by other people's posts.** A reel arrives with
the next five reels of the feed attached, in exactly the same shape. Only the nodes
naming the post in the address are read, so a link to something Facebook no longer has
— which it answers by quietly serving the Watch home page — fails rather than showing
a stranger's video under your link.
- **TikTok sometimes answers with a slider puzzle** instead of the post. You get handed - **TikTok sometimes answers with a slider puzzle** instead of the post. You get handed
the puzzle rather than an error — see below. the puzzle rather than an error — see below.
- **Reddit refuses `.json` to a browser it has never seen.** The first request of a cold - **Reddit refuses `.json` to a browser it has never seen.** The first request of a cold
@@ -254,9 +241,6 @@ link preview offers.
## Development ## Development
Agent instructions live in `AGENTS.md` (source of truth); `CLAUDE.md` holds
adapter-internals detail.
Needs Node 22+. The container commands below use Apple `container`; `docker` takes the Needs Node 22+. The container commands below use Apple `container`; `docker` takes the
same arguments if that is what you have. same arguments if that is what you have.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "antisocial", "name": "antisocial",
"version": "1.3.3", "version": "1.2.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "antisocial", "name": "antisocial",
"version": "1.3.3", "version": "1.2.5",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@fastify/static": "10.1.3", "@fastify/static": "10.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "antisocial", "name": "antisocial",
"version": "1.3.3", "version": "1.2.5",
"private": true, "private": true,
"description": "Reads social posts back to you without the app.", "description": "Reads social posts back to you without the app.",
"license": "UNLICENSED", "license": "UNLICENSED",
-451
View File
@@ -1,451 +0,0 @@
import { ResolveError, oneSegment, type Author, type Media, type Post } from '../types.ts';
import { readOpenGraph } from '../browser/capture.ts';
import { mediaFromOpenGraph, metaHeaders } from './meta-media.ts';
import { hostMatcher, stripTracking } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts';
const REFERER = 'https://www.facebook.com/';
type FbImage = { uri?: string; width?: number; height?: number };
/**
* One picture or one video, in the several shapes Facebook writes them in:
* a photo permalink makes the photo the node itself, a story hangs it off an
* attachment, and a video keeps its files in a sub-object of their own.
*/
type FbMediaNode = {
image?: FbImage;
photo_image?: FbImage;
viewer_image?: FbImage;
accessibility_caption?: string;
width?: number;
height?: number;
length_in_second?: number;
preferred_thumbnail?: { image?: FbImage };
thumbnailImage?: FbImage;
videoDeliveryLegacyFields?: {
browser_native_hd_url?: string;
browser_native_sd_url?: string;
};
};
type FbAttachment = {
media?: FbMediaNode;
styles?: { attachment?: { media?: FbMediaNode } };
all_subattachments?: { nodes?: FbAttachment[] };
};
type FbActor = {
id?: string;
name?: string;
url?: string;
profile_picture?: FbImage;
displayPicture?: FbImage;
};
export type Scraped = {
media: Media[];
text?: string;
author?: FbActor;
postedAt?: string;
};
/**
* Every node in `roots`, breadth-first.
*
* Order is the whole point: within one post the node nearest the top of the
* payload is the post itself, and the ones below it are the neighbouring
* album photo, the comment, the recommendation.
*/
function* nodes(roots: readonly unknown[]): Generator<Record<string, unknown>> {
const seen = new Set<unknown>();
const queue: unknown[] = [...roots];
for (let i = 0; i < queue.length; i += 1) {
const value = queue[i];
if (value === null || typeof value !== 'object' || seen.has(value)) continue;
seen.add(value);
if (Array.isArray(value)) {
queue.push(...value);
continue;
}
yield value as Record<string, unknown>;
queue.push(...Object.values(value));
}
}
/** Two Facebook addresses pointing at the same thing. */
function samePath(a: string, b: string): boolean {
try {
const path = (raw: string): string =>
new URL(raw).pathname.replace(/\/+$/, '').toLowerCase();
return path(a) === path(b) && path(a) !== '';
} catch {
return false;
}
}
/** Whether this node says, one way or another, that it is the linked post. */
function claimsPost(node: Record<string, unknown>, id: string | undefined, url: string): boolean {
if (id !== undefined) {
for (const key of ['id', 'post_id', 'video_id', 'legacy_story_hideable_id']) {
if (node[key] === id) return true;
}
}
for (const key of ['permalink_url', 'wwwURL', 'shareable_url', 'url']) {
const value = node[key];
if (typeof value === 'string' && samePath(value, url)) return true;
}
return false;
}
/**
* The route's own query results.
*
* Used when nothing in the payload names the post. Not the whole payload:
* alongside the post a Facebook page ships its entire client configuration,
* which is thousands of nodes deep and full of objects carrying a `name` or
* an `id`, and searching that finds a video player setting before it finds
* the author.
*/
function queryResults(payloads: readonly unknown[]): unknown[] {
const found: unknown[] = [];
for (const node of nodes(payloads)) {
const result = node['result'];
if (result !== null && typeof result === 'object' && 'data' in result) {
found.push((result as { data: unknown }).data);
}
}
return found;
}
/**
* The parts of the payload that belong to the post that was linked.
*
* A Facebook page is never only the post it was asked for: a reel arrives
* with the next five reels of the feed attached, a video with its related
* videos, and every one of them carries the same fields in the same shape as
* the real one. What separates them is the id, which is in the address and
* repeated on every node of the post it belongs to.
*
* One post's pieces are spread over several of these blocks — a video post
* keeps its files in one and its caption in another — so this collects every
* node that claims the post rather than stopping at the first.
*
* Empty means the page is not the post. Where the address named an id and
* nothing on the page answers to it, Facebook has quietly served something
* else: a `/watch/<id>` whose video is gone comes back as the Watch home
* page, feed and all, and reading that would put a stranger's video under
* the link that was shared. Only an address with no id in it — a `pfbid`
* permalink — falls back to the query results.
*/
export function partsOfPost(
payloads: readonly unknown[],
id: string | undefined,
url: string,
): unknown[] {
const owned: unknown[] = [];
for (const node of nodes(payloads)) {
if (claimsPost(node, id, url)) owned.push(node);
}
if (owned.length > 0) return owned;
return id === undefined ? queryResults(payloads) : [];
}
export function mediaFromNode(node: FbMediaNode): Media[] {
const fetchHeaders = metaHeaders(REFERER);
const legacy = node.videoDeliveryLegacyFields;
const video = legacy?.browser_native_hd_url ?? legacy?.browser_native_sd_url;
if (video) {
const poster = node.preferred_thumbnail?.image?.uri ?? node.thumbnailImage?.uri;
return [{
kind: 'video',
url: video,
fetchHeaders,
...(poster ? { poster: { url: poster, fetchHeaders } } : {}),
...(node.length_in_second ? { durationSec: Math.round(node.length_in_second) } : {}),
...(node.width ? { width: node.width } : {}),
...(node.height ? { height: node.height } : {}),
}];
}
const image = node.photo_image ?? node.image ?? node.viewer_image;
if (!image?.uri) return [];
return [{
kind: 'image',
url: image.uri,
fetchHeaders,
...(node.accessibility_caption ? { alt: node.accessibility_caption } : {}),
...(image.width ? { width: image.width } : {}),
...(image.height ? { height: image.height } : {}),
}];
}
/**
* Everything hanging off a story.
*
* `all_subattachments` is what makes a post of several pictures several
* pictures — but it is shipped on single-picture posts too, as an empty
* list, so only a populated one is a carousel.
*/
export function mediaFromAttachments(attachments: readonly FbAttachment[]): Media[] {
return attachments.flatMap((attachment) => {
const children = attachment.all_subattachments?.nodes;
if (children?.length) return mediaFromAttachments(children);
const node = attachment.styles?.attachment?.media ?? attachment.media;
return node ? mediaFromNode(node) : [];
});
}
function findMedia(owned: readonly unknown[]): Media[] {
// Attachments first: they are the only shape that can hold more than one
// picture, and a post with several would otherwise come back as its first.
for (const node of nodes(owned)) {
const attachments = node['attachments'];
if (!Array.isArray(attachments) || attachments.length === 0) continue;
const media = mediaFromAttachments(attachments as FbAttachment[]);
if (media.length > 0) return media;
}
// A photo permalink has no story around it; the node is the picture.
for (const node of nodes(owned)) {
const media = mediaFromNode(node as FbMediaNode);
if (media.length > 0) return media;
}
return [];
}
function findText(owned: readonly unknown[]): string | undefined {
for (const node of nodes(owned)) {
const message = node['message'];
if (message === null || typeof message !== 'object') continue;
const text = (message as Record<string, unknown>)['text'];
if (typeof text === 'string' && text.length > 0) return text;
}
return undefined;
}
function findPostedAt(owned: readonly unknown[]): string | undefined {
for (const node of nodes(owned)) {
for (const key of ['creation_time', 'created_time']) {
const seconds = node[key];
if (typeof seconds === 'number' && seconds > 0) {
return new Date(seconds * 1000).toISOString();
}
}
}
return undefined;
}
function isActor(value: unknown): value is FbActor {
if (value === null || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
return typeof record['name'] === 'string' && record['name'].length > 0;
}
/**
* Who posted it.
*
* Facebook calls the author of a post its owner, and keeps `author` for the
* author of a *comment* — which sits on the same page, in the same shape,
* with a name and a picture just like the poster's. Taking the first node
* carrying a name finds a commenter as readily as the person being read, so
* only the keys that mean "owner" count.
*
* The name, the vanity address and the picture are rarely all on one node,
* so the gaps are filled from the other nodes describing the same person —
* matched on id, never on position, so a commenter's picture can never end
* up under the poster's name.
*/
export function findAuthor(owned: readonly unknown[]): FbActor | undefined {
const owners: FbActor[] = [];
for (const node of nodes(owned)) {
for (const key of ['actors', 'owner', 'video_owner', 'owner_as_page']) {
const value = node[key];
const actor = Array.isArray(value) ? value[0] : value;
if (isActor(actor)) owners.push(actor);
}
}
const first = owners[0];
if (!first) return undefined;
// An owner with no id of its own is only itself: filling its gaps from
// every other id-less node would be matching on nothing at all.
const same = first.id === undefined ? [first] : owners.filter((actor) => actor.id === first.id);
const picture = same.find((actor) => actor.profile_picture?.uri ?? actor.displayPicture?.uri);
return {
...first,
...(first.url ? {} : { url: same.find((actor) => actor.url)?.url }),
...(picture ? { profile_picture: picture.profile_picture ?? picture.displayPicture } : {}),
};
}
/** Everything the payload has to say about the post that was linked. */
export function scrapeParts(owned: readonly unknown[]): Scraped {
const author = findAuthor(owned);
const text = findText(owned);
const postedAt = findPostedAt(owned);
return {
media: findMedia(owned),
...(text ? { text } : {}),
...(author ? { author } : {}),
...(postedAt ? { postedAt } : {}),
};
}
/**
* The numeric id Facebook keys the post by, wherever this address keeps it.
*
* `/photo/?fbid=`, `/watch/?v=` and `permalink.php?story_fbid=` put it in the
* query; everything else ends on it. `id` is deliberately not read: on
* `permalink.php` it is the page's, not the post's.
*/
export function postIdFrom(url: URL): string | undefined {
for (const key of ['fbid', 'v', 'story_fbid']) {
const value = url.searchParams.get(key);
if (value && /^\d{6,}$/.test(value)) return value;
}
const segments = url.pathname.split('/').filter(Boolean);
return segments.reverse().find((segment) => /^\d{6,}$/.test(segment));
}
/** A profile address is one segment and nothing else. */
function vanityFrom(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
const match = /^\/([A-Za-z0-9.]+)\/?$/.exec(new URL(url).pathname);
const name = match?.[1];
return name && !name.endsWith('.php') ? name : undefined;
} catch {
return undefined;
}
}
/** Shared page links lead with the page: `/nasa/posts/…`, `/nasa/videos/…`. */
function vanityFromPath(pathRest: string): string | undefined {
return /^([A-Za-z0-9.]+)\/(?:posts|videos|photos)\//.exec(pathRest)?.[1];
}
function authorFrom(actor: FbActor | undefined, pathRest: string): Author {
const vanity = vanityFrom(actor?.url) ?? vanityFromPath(pathRest);
const avatar = actor?.profile_picture?.uri ?? actor?.displayPicture?.uri;
// Facebook has no universal @handle: a page has a vanity address, and a
// person may have nothing but a numeric id. Where there is no vanity the
// name is the only thing to call them by — an invented handle would be a
// name nobody could look up.
return {
handle: vanity ? `@${vanity}` : (actor?.name ?? 'Facebook'),
...(actor?.name && vanity ? { displayName: actor.name } : {}),
...(avatar ? { avatar: { url: avatar, fetchHeaders: metaHeaders(REFERER) } } : {}),
};
}
/** The payloads the page ships, which are plain JSON in ordinary `<script>`
* tags rather than the `type="application/json"` Threads uses. */
async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> {
const texts = await ctx.page
.evaluate(() =>
[...document.querySelectorAll('script')]
.map((el) => el.textContent ?? '')
.filter((text) => text.startsWith('{') && text.length > 256),
)
.catch(() => [] as string[]);
return texts.flatMap((text) => {
try {
return [JSON.parse(text) as unknown];
} catch {
return [];
}
});
}
/** Absolute floor: whatever the page offers a link preview. */
async function fromOpenGraph(ctx: ResolveContext): Promise<Scraped> {
const og = await readOpenGraph(ctx.page);
return {
media: mediaFromOpenGraph(og, REFERER),
...(og['og:description'] ? { text: og['og:description'] } : {}),
};
}
async function resolve(ctx: ResolveContext): Promise<Post> {
const { page, pathRest } = ctx;
let originalUrl = ctx.originalUrl;
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
// `/share/…` links are opaque stubs, and what they point at is only on the
// other side of the redirect. Hand back where it landed rather than the
// share code, which says nothing about what it opens.
if (/^share\//.test(pathRest)) {
try {
originalUrl = stripTracking(new URL(page.url())).href;
} catch {
// Whatever it landed on is not a URL we can take apart; keep the one
// we were given.
}
}
const owned = partsOfPost(
await inlinePayloads(ctx),
postIdFrom(new URL(originalUrl)),
originalUrl,
);
if (owned.length === 0) {
throw new ResolveError(
'Facebook served something else for that link — the post may be gone',
'facebook',
originalUrl,
);
}
const scraped = scrapeParts(owned);
if (scraped.media.length === 0 && !scraped.text) {
const og = await fromOpenGraph(ctx);
if (og.media.length > 0 || og.text) {
return toPost({ ...og, ...(scraped.author ? { author: scraped.author } : {}) }, originalUrl, pathRest);
}
throw new ResolveError(
'Facebook would not show that post logged out',
'facebook',
originalUrl,
);
}
return toPost(scraped, originalUrl, pathRest);
}
function toPost(scraped: Scraped, originalUrl: string, pathRest: string): Post {
return {
platform: 'facebook',
platformLabel: 'Facebook',
originalUrl,
author: authorFrom(scraped.author, pathRest),
textPosition: 'above',
segments: oneSegment({
...(scraped.text ? { text: scraped.text } : {}),
media: scraped.media,
...(scraped.postedAt ? { postedAt: scraped.postedAt } : {}),
}),
};
}
export const facebook: PlatformSpec = {
id: 'facebook',
label: 'Facebook',
prefix: 'fb',
canonicalHost: 'www.facebook.com',
textPosition: 'above',
// `m.` and `web.` are the same site behind a login wall, so a link to one
// is rebuilt on `www.` rather than followed where it points.
matchesHost: hostMatcher('facebook.com', 'fb.com', 'fb.watch'),
resolve,
};
+1 -4
View File
@@ -6,11 +6,8 @@ import { instagram } from './instagram.ts';
import { tiktok } from './tiktok.ts'; import { tiktok } from './tiktok.ts';
import { bluesky } from './bluesky.ts'; import { bluesky } from './bluesky.ts';
import { reddit } from './reddit.ts'; import { reddit } from './reddit.ts';
import { facebook } from './facebook.ts';
export const platforms: readonly PlatformSpec[] = [ export const platforms: readonly PlatformSpec[] = [x, threads, instagram, tiktok, bluesky, reddit];
x, threads, instagram, facebook, tiktok, bluesky, reddit,
];
const byPrefix = new Map(platforms.map((p) => [p.prefix, p])); const byPrefix = new Map(platforms.map((p) => [p.prefix, p]));
+19 -27
View File
@@ -1,12 +1,6 @@
import { ResolveError, oneSegment, type Media, type Post } from '../types.ts'; import { ResolveError, oneSegment, type Media, type Post } from '../types.ts';
import { readOpenGraph } from '../browser/capture.ts'; import { readOpenGraph } from '../browser/capture.ts';
import { import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts';
findCaption,
findMetaMedia,
mediaFromMetaNode,
mediaFromOpenGraph,
metaHeaders,
} from './meta-media.ts';
import { extractJsonObjectAfterKey } from './scan.ts'; import { extractJsonObjectAfterKey } from './scan.ts';
import { hostMatcher } from './url.ts'; import { hostMatcher } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts'; import type { PlatformSpec, ResolveContext } from './types.ts';
@@ -208,8 +202,22 @@ function fromPayloads(texts: string[]): Scraped | undefined {
/** Absolute floor: whatever the page offers a link preview. */ /** Absolute floor: whatever the page offers a link preview. */
async function fromOpenGraph(ctx: ResolveContext): Promise<Scraped> { async function fromOpenGraph(ctx: ResolveContext): Promise<Scraped> {
const og = await readOpenGraph(ctx.page); 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 { return {
media: mediaFromOpenGraph(og, REFERER), media,
...(og['og:description'] ? { text: og['og:description'] } : {}), ...(og['og:description'] ? { text: og['og:description'] } : {}),
}; };
} }
@@ -254,31 +262,15 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
if (scraped && !scraped.text && dom.text) scraped.text = dom.text; if (scraped && !scraped.text && dom.text) scraped.text = dom.text;
if (scraped && !scraped.handle && dom.handle) scraped.handle = dom.handle; if (scraped && !scraped.handle && dom.handle) scraped.handle = dom.handle;
// The embed no longer ships a reel's `video_url`, and its player never if (!scraped?.media.length) {
// draws a `<video>` logged out — so both payload and DOM hand back only
// the cover frame. An image where a video belongs is a miss, not a result:
// fall through to the post page, whose payload still carries
// `video_versions`. Keep the embed's caption/handle/avatar either way.
const hasVideo = scraped?.media.some((item) => item.kind === 'video') === true;
if (!scraped?.media.length || (expectsVideo && !hasVideo)) {
// The embed refuses some posts outright ("the link may be broken"). // The embed refuses some posts outright ("the link may be broken").
// Try the post itself: its payload first, then its link preview. // Try the post itself: its payload first, then its link preview.
const carry = scraped;
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined); await page.goto(originalUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
const fromPost = fromPayloads(await scriptTexts(ctx)); scraped = fromPayloads(await scriptTexts(ctx)) ?? scraped;
if (fromPost?.media.length) {
scraped = {
...fromPost,
...(fromPost.text ? {} : carry?.text ? { text: carry.text } : {}),
...(fromPost.handle ? {} : carry?.handle ? { handle: carry.handle } : {}),
...(fromPost.avatar ? {} : carry?.avatar ? { avatar: carry.avatar } : {}),
...(fromPost.displayName ? {} : carry?.displayName ? { displayName: carry.displayName } : {}),
};
}
if (!scraped?.media.length) { if (!scraped?.media.length) {
const og = await fromOpenGraph(ctx); const og = await fromOpenGraph(ctx);
if (og.media.length) scraped = { ...og, ...(carry?.handle ? { handle: carry.handle } : {}) }; if (og.media.length) scraped = { ...og, ...(scraped?.handle ? { handle: scraped.handle } : {}) };
} }
} }
+2 -25
View File
@@ -1,10 +1,8 @@
import type { Media } from '../types.ts'; import type { Media } from '../types.ts';
/** /**
* Instagram, Threads and Facebook are the same product underneath. The first * Instagram and Threads are the same product underneath and serve the same
* two serve the same media schema, so both normalize through here; Facebook's * media schema, so both adapters normalize through here.
* 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 }; type Candidate = { url?: string; width?: number; height?: number };
@@ -68,27 +66,6 @@ export function mediaFromMetaNode(node: MetaMediaNode, referer: string): Media[]
return []; 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 { function nonEmptyArray(value: unknown): boolean {
return Array.isArray(value) && value.length > 0; return Array.isArray(value) && value.length > 0;
} }
+15 -9
View File
@@ -1,12 +1,6 @@
import { ResolveError, type Post, type Segment } from '../types.ts'; import { ResolveError, type Media, type Post, type Segment } from '../types.ts';
import { readOpenGraph } from '../browser/capture.ts'; import { readOpenGraph } from '../browser/capture.ts';
import { import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders, type MetaMediaNode } from './meta-media.ts';
findCaption,
findMetaMedia,
mediaFromMetaNode,
mediaFromOpenGraph,
type MetaMediaNode,
} from './meta-media.ts';
import { hostMatcher } from './url.ts'; import { hostMatcher } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts'; import type { PlatformSpec, ResolveContext } from './types.ts';
@@ -135,7 +129,19 @@ function segmentOf(post: ThreadsPost, isAnchor: boolean): Segment {
/** The floor, when the structured payload is not there to be read. */ /** The floor, when the structured payload is not there to be read. */
async function fromOpenGraph(ctx: ResolveContext): Promise<Segment | undefined> { async function fromOpenGraph(ctx: ResolveContext): Promise<Segment | undefined> {
const og = await readOpenGraph(ctx.page); const og = await readOpenGraph(ctx.page);
const media = mediaFromOpenGraph(og, REFERER); 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 text = og['og:description']; const text = og['og:description'];
if (media.length === 0 && !text) return undefined; if (media.length === 0 && !text) return undefined;
-1
View File
@@ -5,7 +5,6 @@
*/ */
const TRACKING_PARAMS = new Set([ const TRACKING_PARAMS = new Set([
'igsh', 'igshid', 'img_index', 'fbclid', 'gclid', 'mibextid', 'igsh', 'igshid', 'img_index', 'fbclid', 'gclid', 'mibextid',
'rdid', 'share_url', 'fs',
'ref_src', 'ref_url', 'ref', 'source', 'checksum', 'social_sharing', 'ref_src', 'ref_url', 'ref', 'source', 'checksum', 'social_sharing',
's', 't', 'si', 'xmt', '_r', '_t', '_d', 's', 't', 'si', 'xmt', '_r', '_t', '_d',
'is_from_webapp', 'sender_device', 'sender_web_id', 'web_id', 'is_from_webapp', 'sender_device', 'sender_web_id', 'web_id',
-1
View File
@@ -29,7 +29,6 @@ const ACCENT: Record<string, { color: string; monogram: string }> = {
x: { color: '#0f1419', monogram: '✕' }, x: { color: '#0f1419', monogram: '✕' },
threads: { color: '#3b3b3b', monogram: '@' }, threads: { color: '#3b3b3b', monogram: '@' },
instagram: { color: '#c13584', monogram: 'IG' }, instagram: { color: '#c13584', monogram: 'IG' },
facebook: { color: '#0866ff', monogram: 'f' },
tiktok: { color: '#fe2c55', monogram: '♪' }, tiktok: { color: '#fe2c55', monogram: '♪' },
bluesky: { color: '#0085ff', monogram: 'B' }, bluesky: { color: '#0085ff', monogram: 'B' },
reddit: { color: '#ff4500', monogram: 'r' }, reddit: { color: '#ff4500', monogram: 'r' },
-1
View File
@@ -6,7 +6,6 @@ const PROFILE_BASE: Record<PlatformId, string> = {
x: 'https://x.com/', x: 'https://x.com/',
threads: 'https://www.threads.com/@', threads: 'https://www.threads.com/@',
instagram: 'https://www.instagram.com/', instagram: 'https://www.instagram.com/',
facebook: 'https://www.facebook.com/',
tiktok: 'https://www.tiktok.com/@', tiktok: 'https://www.tiktok.com/@',
bluesky: 'https://bsky.app/profile/', bluesky: 'https://bsky.app/profile/',
reddit: 'https://www.reddit.com/user/', reddit: 'https://www.reddit.com/user/',
+1 -2
View File
@@ -1,6 +1,5 @@
/** The platforms antisocial understands. */ /** The platforms antisocial understands. */
export type PlatformId = export type PlatformId = 'x' | 'threads' | 'instagram' | 'tiktok' | 'bluesky' | 'reddit';
| 'x' | 'threads' | 'instagram' | 'facebook' | 'tiktok' | 'bluesky' | 'reddit';
/** /**
* Something fetchable that lives on someone else's CDN. * Something fetchable that lives on someone else's CDN.
-191
View File
@@ -1,191 +0,0 @@
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');
});
File diff suppressed because it is too large Load Diff
-775
View File
@@ -1,775 +0,0 @@
[
{
"require": [
[
"ScheduledServerJS",
"handle",
null,
[
{
"__bbox": {
"define": [
[
"VideoPlayerShakaPerformanceLoggerConfig",
[],
{
"isSampled": false
},
1760
]
],
"require": [
[
"useVideoPlayerShakaPerformanceLoggerRelayImpl_init$normalization.graphql"
],
[
"VideoPlayerShakaPerformanceLogger"
],
[
"useVideoPlayerShakaPerformanceLoggerBuilder_init$normalization.graphql"
],
[
"VideoPlayerShakaPerformanceLoggerBuilder"
],
[
"emptyFunction",
"thatReturns",
[
"RequireDeferredReference"
],
[
[
{
"__dr": "useVideoPlayerShakaPerformanceLoggerRelayImpl_init$normalization.graphql"
},
{
"__dr": "VideoPlayerShakaPerformanceLogger"
},
{
"__dr": "useVideoPlayerShakaPerformanceLoggerBuilder_init$normalization.graphql"
},
{
"__dr": "VideoPlayerShakaPerformanceLoggerBuilder"
}
]
]
],
[
"RelayPrefetchedStreamCache",
"next",
[],
[
"adp_FBReelsRootWithEntrypointQueryRelayPreloader_6aa55a2ad72a56521899979",
{
"__bbox": {
"complete": false,
"result": {
"data": {
"video": {
"creation_story": {
"short_form_video_context": {
"self_view_boost": null,
"video": {
"id": "962551420197596",
"first_frame_thumbnail": "https://scontent-lga3-3.xx.fbcdn.net/v/t15.5256-10/789978403_1809099130521122_6040441097728922839_n.jpg?stp=dst-jpg_tt6&cstp=mx720x1280&ctp=s720x1280&_nc_cat=102&ccb=1-7&_nc_sid=5fad0e&_nc_ohc=DvPOdqdEQNEQ7kNvwHnYHcF&_nc_oc=AdpAQYs6MUrh7K_OCrf910OmxkBIsJIqpTHeBaiPySfFaTTDWaTVE8phgrp1f0D9TZ0&_nc_zt=23&_nc_ht=scontent-lga3-3.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKEdQnt-qQQOSPFDO8-hSInQQiX2bH18d8eyb1VYXJLCQ&oe=6AAB29D3",
"__typename": "Video",
"playable_duration_in_ms": 14952,
"embeddable": true,
"owner": {
"__typename": "User",
"id": "100076751910580"
},
"audio_availability": "AVAILABLE",
"seo_llm_content": null
},
"if_should_change_url_for_reels": null,
"shareable_url": "https://www.facebook.com/reel/962551420197596",
"playback_video": {
"aspect_ratio": 0.5625,
"height": 1280,
"width": 720,
"length_in_second": 14.952,
"id": "962551420197596",
"dubbed_track_mapping": [],
"thumbnailImage": {
"uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t15.5256-10/789706769_1556749619267719_4416772935294433768_n.jpg?stp=dst-jpg_tt6&cstp=mx720x1280&ctp=s960x960&_nc_cat=101&ccb=1-7&_nc_sid=5fad0e&_nc_ohc=YIFZQCxz3MQQ7kNvwEZs12e&_nc_oc=AdpS4HDG305iIN7lMtXk_yVOZzhXmR2k1smCnFXHeEj-JhUnua_ywiQIxb6anRE6yhY&_nc_zt=23&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQJmcHNS0DS_a1T6Tsxz072XU2rRAD5ux2IaGXJcJmJJvQ&oe=6AAB402F"
},
"animated_image_caption": null,
"broadcaster_origin": null,
"broadcast_id": null,
"broadcast_status": null,
"is_live_streaming": false,
"is_live_trace_enabled": false,
"is_looping": true,
"is_video_broadcast": false,
"is_podcast_video": false,
"loop_count": 6,
"is_spherical": false,
"is_spherical_enabled": true,
"unsupported_browser_message": null,
"pmv_metadata": null,
"latency_sensitive_config": null,
"live_playback_instrumentation_configs": null,
"is_ncsr": false,
"permalink_url": "https://www.facebook.com/reel/962551420197596/",
"seo_web_crawler_lookaside_url": null,
"video_status_type": "OK",
"can_use_oz": true,
"min_quality_preference": null,
"spherical_video_fallback_urls": null,
"comet_video_player_nextgendash_availability": "AVAILABLE",
"videoDeliveryLegacyFields": {
"dash_manifest_url": "https://www.facebook.com/dash_mpd_debug.mpd?v=962551420197596&dummy=.mpd",
"browser_native_sd_url": "https://video-lga3-1.xx.fbcdn.net/o1/v/t2/f2/m412/AQNPfClq4XBdFUYWXh5JJcADVGPYjlQAyyfGGsMvzGU39oqvYXd-DobOpXNWxfUxl24ISRJrHRyGe-PuNwE3KWVHDREA6ziGSzebPeUcxQ.mp4?_nc_cat=110&_nc_sid=8bf8fe&_nc_ht=video-lga3-1.xx.fbcdn.net&_nc_ohc=zQ3ivCtauyoQ7kNvwEidn40&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuMzYwLnN2ZV9zZCIsInhwdl9hc3NldF9pZCI6MzEwMjgwMjI4MzI1NTg0MSwiYXNzZXRfYWdlX2RheXMiOjEyLCJ2aV91c2VjYXNlX2lkIjoxMDEyMiwiZHVyYXRpb25fcyI6MTQsInVybGdlbl9zb3VyY2UiOiJ3d3cifQ%3D%3D&ccb=17-1&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQKAmLf2hie1QceS3NEtBpozi3IzBvx05iysQQ4udnzEmA&oe=6AAB36F8&bitrate=250233&tag=sve_sd",
"browser_native_hd_url": "https://video-lga3-3.xx.fbcdn.net/o1/v/t2/f2/m366/AQP2uXO3e__FC55WpiTUHKGsjhpA8irwEiU9M-ep75IQJXDPWw-rcn5BOLIh37rWgi0F66T2tvsq-M26duEVA7YNjcJxc5vC0RRKAqcBMLLWYw.mp4?_nc_cat=106&_nc_sid=5e9851&_nc_ht=video-lga3-3.xx.fbcdn.net&_nc_ohc=yuT4HCi5mtMQ7kNvwGVmgn8&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuNzIwLmRhc2hfaDI2NC1iYXNpYy1nZW4yXzcyMHAiLCJ4cHZfYXNzZXRfaWQiOjMxMDI4MDIyODMyNTU4NDEsImFzc2V0X2FnZV9kYXlzIjoxMiwidmlfdXNlY2FzZV9pZCI6MTAxMjIsImR1cmF0aW9uX3MiOjE0LCJ1cmxnZW5fc291cmNlIjoid3d3In0%3D&ccb=17-1&vs=6c8f2d6921f22687&_nc_vs=HBksFQIYRWZiX2VwaGVtZXJhbC9GMjQyRTUwQzVDQ0U4RkE2QTE3NjExQzU4RkU4QzZBMl9tdF8xX3ZpZGVvX2Rhc2hpbml0Lm1wNBUAAsgBEgAVAhhAZmJfcGVybWFuZW50Lzc5NDUxM0FGMjVBOUJBMDgyQTBGMUJFODFCRDk2NEFFX2F1ZGlvX2Rhc2hpbml0Lm1wNBUCAsgBEgAoABgAGwKIB3VzZV9vaWwBMRJwcm9ncmVzc2l2ZV9yZWNpcGUBMRUAACbC4La-7v6CCxUCKAJDMywXQC3dsi0OVgQYGWRhc2hfaDI2NC1iYXNpYy1nZW4yXzcyMHARAHUCZZSeAQA&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQKAQHnGYXQYLb6DgP81xMw4ISjcqBkBSHSu0taJI5ciGg&oe=6AAB3B02&bitrate=941272&tag=dash_h264-basic-gen2_720p",
"id": "962551420197596"
},
"is_gaming_video": false,
"is_latency_menu_enabled": false,
"fbls_tier": null,
"is_latency_sensitive_broadcast": false,
"video_player_shaka_performance_logger_should_sample": false,
"autoplay_gating_result": "gatekeeper",
"viewer_autoplay_setting": "default_autoplay",
"can_autoplay": false,
"drm_info": "{\"video_license_uri_map\":{},\"graph_api_video_license_uri\":null,\"fairplay_cert\":null,\"widevine_cert\":\"CsECCAMSEBcFuRfMEgSGiwYzOi93KowYgrSCkgUijgIwggEKAoIBAQCZ7Vs7Mn2rXiTvw7YqlbWYUgrVvMs3UD4GRbgU2Ha430BRBEGtjOOtsRu4jE5yWl5KngeVKR1YWEAjp+GvDjipEnk5MAhhC28VjIeMfiG\\/+\\/7qd+EBnh5XgeikX0YmPRTmDoBYqGB63OBPrIRXsTeo1nzN6zNwXZg6IftO7L1KEMpHSQykfqpdQ4IY3brxyt4zkvE9b\\/tkQv0x4b9AsMYE0cS6TJUgpL+X7r1gkpr87vVbuvVk4tDnbNfFXHOggrmWEguDWe3OJHBwgmgNb2fG2CxKxfMTRJCnTuw3r0svAQxZ6ChD4lgvC2ufXbD8Xm7fZPvTCLRxG88SUAGcn1oJAgMBAAE6FGxpY2Vuc2Uud2lkZXZpbmUuY29tEoADrjRzFLWoNSl\\/JxOI+3u4y1J30kmCPN3R2jC5MzlRHrPMveoEuUS5J8EhNG79verJ1BORfm7BdqEEOEYKUDvBlSubpOTOD8S\\/wgqYCKqvS\\/zRnB3PzfV0zKwo0bQQQWz53ogEMBy9szTK\\/NDUCXhCOmQuVGE98K\\/PlspKkknYVeQrOnA+8XZ\\/apvTbWv4K+drvwy6T95Z0qvMdv62Qke4XEMfvKUiZrYZ\\/DaXlUP8qcu9u\\/r6DhpV51Wjx7zmVflkb1gquc9wqgi5efhn9joLK3\\/bNixbxOzVVdhbyqnFk8ODyFfUnaq3fkC3hR3f0kmYgI41sljnXXjqwMoW9wRzBMINk+3k6P8cbxfmJD4\\/Paj8FwmHDsRfuoI6Jj8M76H3CTsZCZKDJjM3BQQ6Kb2m+bQ0LMjfVDyxoRgvfF\\/\\/M\\/EEkPrKWyU2C3YBXpxaBquO4C8A0ujVmGEEqsxN1HX9lu6c5OMm8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0\"}",
"p2p_settings": null,
"audio_settings": null,
"captions_settings": null,
"broadcast_low_latency_config": null,
"audio_availability": "AVAILABLE",
"muted_segments": [],
"spherical_video_renderer": null,
"preferred_thumbnail": {
"image": {
"uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t15.5256-10/789706769_1556749619267719_4416772935294433768_n.jpg?stp=dst-jpg_tt6&cstp=mx720x1280&ctp=s960x960&_nc_cat=101&ccb=1-7&_nc_sid=50ce42&_nc_ohc=YIFZQCxz3MQQ7kNvwEZs12e&_nc_oc=AdpS4HDG305iIN7lMtXk_yVOZzhXmR2k1smCnFXHeEj-JhUnua_ywiQIxb6anRE6yhY&_nc_zt=23&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQJmcHNS0DS_a1T6Tsxz072XU2rRAD5ux2IaGXJcJmJJvQ&oe=6AAB402F"
},
"id": "1605853321074011"
},
"video_imf_data": null,
"playback_language_on_comet": null,
"warning_screen_renderer": null,
"cix_screen": null
},
"video_owner": {
"__typename": "User",
"id": "100076751910580",
"__isActor": "User",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof",
"displayPicture": {
"uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s40x40&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C"
},
"should_show_reels_follow_back_cta": false,
"subscribe_status": "CANNOT_SUBSCRIBE",
"delegate_page": {
"id": "103285455517243",
"uri_token": "thespeechprof",
"woodhenge_creator_info": {
"viewer_entrypoint_eligibility": {
"is_eligible": true
},
"id": "272843125228141:FubsCreatorInfo",
"viewer_active_subscription_entitlement": null
}
}
},
"fb_shorts_reshare_context": {
"is_reshare": false,
"reshare_creator": {
"__typename": "User",
"__isActor": "User",
"id": "100076751910580",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof"
}
},
"remix_info": {
"is_remixable": true,
"status": "DISABLED"
},
"video_owner_type": "FACEBOOK_USER",
"soundtrack_info": {
"id": "38012539085060844",
"type": "CUSTOM_AUDIO"
},
"track_title": "SpeechProf · Original audio",
"music_album_art_uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s64x64&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C",
"is_original_audio_on_facebook": true
},
"id": "UzpfSTEwMDA3Njc1MTkxMDU4MDpWSzo5NjI1NTE0MjAxOTc1OTY=",
"sponsored_data": null,
"brand_safety_data": null,
"post_id": "1101740529061001",
"creation_time": 1788104325,
"unpublished_content_type": "PUBLISHED",
"scheduled_publish_time": null,
"sponsor_relationship": 0,
"branded_content_post_info": null,
"creator_collaboration": null,
"media_attribution_elements": [],
"attachments": [
{
"media": {
"__typename": "Video",
"is_made_with_meta_ai_app": false,
"__isNode": "Video",
"id": "962551420197596",
"video_delivery_response": {
"transcript_urls": [],
"id": "962551420197596"
}
},
"call_to_action_renderer": null
}
],
"can_viewer_delete": false,
"can_viewer_edit": false,
"can_viewer_cancel_collaboration_invite": false,
"can_viewer_remove_collaborator": false,
"can_viewer_see_collaboration_invite": false,
"legal_reporting_cta_type": null,
"legal_reporting_uri": null,
"to": null,
"video": {
"id": "962551420197596",
"owner": {
"__typename": "User",
"id": "100076751910580"
}
},
"post_collaboration": null,
"can_viewer_remove_self_as_collaborator": false,
"if_viewer_can_see_stars_toggle_menu_option": null,
"transparency_ad_info": {
"menu_label": "Ad info",
"should_display_ad_info": false
},
"debug_info": null,
"inform_treatment_for_community_notes": null,
"message": {
"text": "I need answers.",
"ranges": []
},
"translated_message_for_viewer": null,
"contextualElementShortFormContext": {
"video_label": null
}
},
"id": "962551420197596"
},
"viewer": {
"actor": {
"__typename": "LoggedOutUser",
"id": "0"
},
"lasso_blue_feed": {
"edges": [
{
"node": {
"__typename": "Story",
"short_form_video_context": {
"self_view_boost": null,
"video": {
"id": "1721985652466360",
"first_frame_thumbnail": "https://scontent-lga3-2.xx.fbcdn.net/v/t15.5256-10/755945469_1271098651617653_7681040076315983851_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=105&ccb=1-7&_nc_sid=b5ba86&_nc_ohc=5cMmm9pIok4Q7kNvwEzT1-_&_nc_oc=AdrC14K89EUG1eO6WsgKZ7T0H3PoqbkQVsGgORdcqOrB5EbSyFcxt0q3XqKPbmD9o6g&_nc_zt=23&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQIQeJM0ARbUsTVHCMOBhG4939d2FggfbBg5YX45AGDAtA&oe=6AAB1721",
"__typename": "Video",
"playable_duration_in_ms": 107233,
"embeddable": true,
"owner": {
"__typename": "User",
"id": "100076751910580"
},
"audio_availability": "AVAILABLE",
"seo_llm_content": null
},
"if_should_change_url_for_reels": {
"shareable_url": "https://www.facebook.com/reel/1721985652466360"
},
"shareable_url": "https://www.facebook.com/reel/1721985652466360",
"playback_video": {
"aspect_ratio": 0.5625,
"height": 1920,
"width": 1080,
"length_in_second": 107.233,
"id": "1721985652466360",
"dubbed_track_mapping": [
{
"audio_lang": "en",
"dubbing_type": "NONE"
}
],
"thumbnailImage": {
"uri": "https://scontent-lga3-3.xx.fbcdn.net/v/t15.5256-10/755514381_1689451229009756_2858366957534701310_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=104&ccb=1-7&_nc_sid=c44d43&_nc_ohc=f7YWmE4qvMYQ7kNvwG8Bmk2&_nc_oc=Adqe-TjWeH2ITkplOCr9oA3aHX5XUhnuZYy7MioMUnoaRIoTagnhs39xaOoGIahnrsc&_nc_zt=23&_nc_ht=scontent-lga3-3.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQIvEn9kMBhuPSZIXhuG7InSXTDNN2zCAHFfj-MXFkILew&oe=6AAB265D"
},
"animated_image_caption": null,
"broadcaster_origin": null,
"broadcast_id": null,
"broadcast_status": null,
"is_live_streaming": false,
"is_live_trace_enabled": false,
"is_looping": false,
"is_video_broadcast": false,
"is_podcast_video": false,
"loop_count": 0,
"is_spherical": false,
"is_spherical_enabled": true,
"unsupported_browser_message": null,
"pmv_metadata": null,
"latency_sensitive_config": null,
"live_playback_instrumentation_configs": null,
"is_ncsr": false,
"permalink_url": "https://www.facebook.com/reel/1721985652466360/",
"seo_web_crawler_lookaside_url": null,
"video_status_type": "OK",
"can_use_oz": true,
"min_quality_preference": null,
"spherical_video_fallback_urls": null,
"comet_video_player_nextgendash_availability": "AVAILABLE",
"videoDeliveryLegacyFields": {
"dash_manifest_url": "https://www.facebook.com/dash_mpd_debug.mpd?v=1721985652466360&dummy=.mpd",
"browser_native_sd_url": "https://video-lga3-2.xx.fbcdn.net/o1/v/t2/f2/m412/AQOHWkcH4C7iUUWXN0tX5u0LJNAHnx3OtpwNMi6E63W4IfnT0T2LXEH6jmsxvG2a_mlrrKP2D0DbBcSW-3m-5fcz6AUQNNn7ui5EjmStQw.mp4?_nc_cat=105&_nc_sid=8bf8fe&_nc_ht=video-lga3-2.xx.fbcdn.net&_nc_ohc=G6DkTtm4mFkQ7kNvwHMHbsC&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuMzYwLnN2ZV9zZCIsInhwdl9hc3NldF9pZCI6MjkwMTQzNjk3MzUzNTkyNCwiYXNzZXRfYWdlX2RheXMiOjQ5LCJ2aV91c2VjYXNlX2lkIjoxMDgyNSwiZHVyYXRpb25fcyI6MTA3LCJ1cmxnZW5fc291cmNlIjoid3d3In0%3D&ccb=17-1&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQIhF8WnKBSuiVFX9HYqM2qgXgxP-mCS_6UrSYY4jiZkEA&oe=6AAB2249&bitrate=338983&tag=sve_sd",
"browser_native_hd_url": "https://video-lga3-1.xx.fbcdn.net/o1/v/t2/f2/m412/AQNyuAvPxz3Ft8_ik8vpXP2aUhrMhSpSQAg5o4N85dqWAsqVeIJe91ZIVkxfEhCu4vH3IvSJFAbVegkaGzxCelTdgxWQYdbJAicJKZnY4YYUtw.mp4?_nc_cat=103&_nc_sid=5e9851&_nc_ht=video-lga3-1.xx.fbcdn.net&_nc_ohc=raTcNflJNr0Q7kNvwFlUu9s&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuMTA4MC5kYXNoX2gyNjQtYmFzaWMtZ2VuMl8xMDgwcCIsInhwdl9hc3NldF9pZCI6MjkwMTQzNjk3MzUzNTkyNCwiYXNzZXRfYWdlX2RheXMiOjQ5LCJ2aV91c2VjYXNlX2lkIjoxMDgyNSwiZHVyYXRpb25fcyI6MTA3LCJ1cmxnZW5fc291cmNlIjoid3d3In0%3D&ccb=17-1&vs=70ff53297466a1d2&_nc_vs=HBksFQIYRWZiX3Blcm1hbmVudC9CNTQ4NUVGMUM4NjlENjFBMjZERUUwMUY3M0U5QjVCMV9tdF8xX3ZpZGVvX2Rhc2hpbml0Lm1wNBUAAsgBEgAVAhhAZmJfcGVybWFuZW50L0Y3NDM0MDZBMDMyRTlEOERGQzQwRTBGMUJEQTA2NDgxX2F1ZGlvX2Rhc2hpbml0Lm1wNBUCAsgBEgAoABgAGwKIB3VzZV9vaWwBMRJwcm9ncmVzc2l2ZV9yZWNpcGUBMRUAACboyqqC7rWnChUCKAJDMywXQFrO6XjU_fQYGmRhc2hfaDI2NC1iYXNpYy1nZW4yXzEwODBwEQB1AmWSqQEA&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQIDxdABoLhIUTddsoY3e8lEb75XZeoo2nzFYg50CsGZRw&oe=6AAB2D71&bitrate=2157493&tag=dash_h264-basic-gen2_1080p",
"id": "1721985652466360"
},
"is_gaming_video": false,
"is_latency_menu_enabled": false,
"fbls_tier": null,
"is_latency_sensitive_broadcast": false,
"video_player_shaka_performance_logger_should_sample": false,
"autoplay_gating_result": "gatekeeper",
"viewer_autoplay_setting": "default_autoplay",
"can_autoplay": false,
"drm_info": "{\"video_license_uri_map\":{},\"graph_api_video_license_uri\":null,\"fairplay_cert\":null,\"widevine_cert\":\"CsECCAMSEBcFuRfMEgSGiwYzOi93KowYgrSCkgUijgIwggEKAoIBAQCZ7Vs7Mn2rXiTvw7YqlbWYUgrVvMs3UD4GRbgU2Ha430BRBEGtjOOtsRu4jE5yWl5KngeVKR1YWEAjp+GvDjipEnk5MAhhC28VjIeMfiG\\/+\\/7qd+EBnh5XgeikX0YmPRTmDoBYqGB63OBPrIRXsTeo1nzN6zNwXZg6IftO7L1KEMpHSQykfqpdQ4IY3brxyt4zkvE9b\\/tkQv0x4b9AsMYE0cS6TJUgpL+X7r1gkpr87vVbuvVk4tDnbNfFXHOggrmWEguDWe3OJHBwgmgNb2fG2CxKxfMTRJCnTuw3r0svAQxZ6ChD4lgvC2ufXbD8Xm7fZPvTCLRxG88SUAGcn1oJAgMBAAE6FGxpY2Vuc2Uud2lkZXZpbmUuY29tEoADrjRzFLWoNSl\\/JxOI+3u4y1J30kmCPN3R2jC5MzlRHrPMveoEuUS5J8EhNG79verJ1BORfm7BdqEEOEYKUDvBlSubpOTOD8S\\/wgqYCKqvS\\/zRnB3PzfV0zKwo0bQQQWz53ogEMBy9szTK\\/NDUCXhCOmQuVGE98K\\/PlspKkknYVeQrOnA+8XZ\\/apvTbWv4K+drvwy6T95Z0qvMdv62Qke4XEMfvKUiZrYZ\\/DaXlUP8qcu9u\\/r6DhpV51Wjx7zmVflkb1gquc9wqgi5efhn9joLK3\\/bNixbxOzVVdhbyqnFk8ODyFfUnaq3fkC3hR3f0kmYgI41sljnXXjqwMoW9wRzBMINk+3k6P8cbxfmJD4\\/Paj8FwmHDsRfuoI6Jj8M76H3CTsZCZKDJjM3BQQ6Kb2m+bQ0LMjfVDyxoRgvfF\\/\\/M\\/EEkPrKWyU2C3YBXpxaBquO4C8A0ujVmGEEqsxN1HX9lu6c5OMm8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0\"}",
"p2p_settings": null,
"audio_settings": null,
"captions_settings": null,
"broadcast_low_latency_config": null,
"audio_availability": "AVAILABLE",
"muted_segments": [],
"spherical_video_renderer": null,
"preferred_thumbnail": {
"image": {
"uri": "https://scontent-lga3-3.xx.fbcdn.net/v/t15.5256-10/755514381_1689451229009756_2858366957534701310_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=104&ccb=1-7&_nc_sid=50ce42&_nc_ohc=f7YWmE4qvMYQ7kNvwG8Bmk2&_nc_oc=Adqe-TjWeH2ITkplOCr9oA3aHX5XUhnuZYy7MioMUnoaRIoTagnhs39xaOoGIahnrsc&_nc_zt=23&_nc_ht=scontent-lga3-3.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQIvEn9kMBhuPSZIXhuG7InSXTDNN2zCAHFfj-MXFkILew&oe=6AAB265D"
},
"id": "2901437906869164"
},
"video_imf_data": null,
"playback_language_on_comet": null,
"warning_screen_renderer": null,
"cix_screen": null
},
"video_owner": {
"__typename": "User",
"id": "100076751910580",
"__isActor": "User",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof",
"displayPicture": {
"uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s40x40&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C"
},
"should_show_reels_follow_back_cta": false,
"subscribe_status": "CANNOT_SUBSCRIBE",
"delegate_page": {
"id": "103285455517243",
"uri_token": "thespeechprof",
"woodhenge_creator_info": {
"viewer_entrypoint_eligibility": {
"is_eligible": true
},
"id": "272843125228141:FubsCreatorInfo",
"viewer_active_subscription_entitlement": null
}
}
},
"fb_shorts_reshare_context": {
"is_reshare": false,
"reshare_creator": {
"__typename": "User",
"__isActor": "User",
"id": "100076751910580",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof"
}
},
"remix_info": {
"is_remixable": false,
"status": "DISABLED"
},
"video_owner_type": "FACEBOOK_USER",
"soundtrack_info": {
"id": "28167383946201489",
"type": "CUSTOM_AUDIO"
},
"track_title": "SpeechProf · Original audio",
"music_album_art_uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s64x64&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C",
"is_original_audio_on_facebook": true
},
"id": "UzpfSTEwMDA3Njc1MTkxMDU4MDpWSzoxNzIxOTg1NjUyNDY2MzYw",
"sponsored_data": null,
"brand_safety_data": null,
"post_id": "1069408425627545",
"creation_time": 1784917622,
"unpublished_content_type": "PUBLISHED",
"scheduled_publish_time": null,
"sponsor_relationship": 0,
"branded_content_post_info": null,
"creator_collaboration": null,
"media_attribution_elements": [],
"attachments": [
{
"media": {
"__typename": "Video",
"is_made_with_meta_ai_app": false,
"__isNode": "Video",
"id": "1721985652466360",
"video_delivery_response": {
"transcript_urls": [],
"id": "1721985652466360"
}
},
"call_to_action_renderer": null
}
],
"can_viewer_delete": false,
"can_viewer_edit": false,
"can_viewer_cancel_collaboration_invite": false,
"can_viewer_remove_collaborator": false,
"can_viewer_see_collaboration_invite": false,
"legal_reporting_cta_type": null,
"legal_reporting_uri": null,
"to": null,
"video": {
"id": "1721985652466360",
"owner": {
"__typename": "User",
"id": "100076751910580"
}
},
"post_collaboration": null,
"can_viewer_remove_self_as_collaborator": false,
"if_viewer_can_see_stars_toggle_menu_option": null,
"transparency_ad_info": {
"menu_label": "Ad info",
"should_display_ad_info": false
},
"debug_info": null,
"inform_treatment_for_community_notes": null,
"message": {
"text": "They wanted the SAVE Act. They arent going to get it.",
"ranges": []
},
"translated_message_for_viewer": null,
"contextualElementShortFormContext": {
"video_label": null
},
"__isNode": "Story"
},
"cursor": "0",
"id": "UzpfSTEwMDA3Njc1MTkxMDU4MDpWSzoxNzIxOTg1NjUyNDY2MzYw"
},
{
"node": {
"__typename": "Story",
"short_form_video_context": {
"self_view_boost": null,
"video": {
"id": "4394633517486098",
"first_frame_thumbnail": "https://scontent-lga3-1.xx.fbcdn.net/v/t15.5256-10/752380652_1765886184766289_5936699386843725422_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=111&ccb=1-7&_nc_sid=b5ba86&_nc_ohc=mwRXs8tE_5oQ7kNvwEgO6W7&_nc_oc=Adr6TYRYlu6q7xQSxkujTNt9dpCZrTybcXtIlM3oU0CqfvcDeWo73h315beqL_9np1o&_nc_zt=23&_nc_ht=scontent-lga3-1.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKndI8uzSgZXENjW1eoBo_E4cF2TfD9-1Mq_v0JFBsCqg&oe=6AAB322D",
"__typename": "Video",
"playable_duration_in_ms": 86233,
"embeddable": true,
"owner": {
"__typename": "User",
"id": "100076751910580"
},
"audio_availability": "AVAILABLE",
"seo_llm_content": null
},
"if_should_change_url_for_reels": {
"shareable_url": "https://www.facebook.com/reel/4394633517486098"
},
"shareable_url": "https://www.facebook.com/reel/4394633517486098",
"playback_video": {
"aspect_ratio": 0.5625,
"height": 1920,
"width": 1080,
"length_in_second": 86.233,
"id": "4394633517486098",
"dubbed_track_mapping": [
{
"audio_lang": "en",
"dubbing_type": "NONE"
}
],
"thumbnailImage": {
"uri": "https://scontent-lga3-1.xx.fbcdn.net/v/t15.5256-10/753306317_2059899598252019_7051428696037703086_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=110&ccb=1-7&_nc_sid=c44d43&_nc_ohc=fFK4qXTnhggQ7kNvwFoTF67&_nc_oc=Adpv6Qoge3bNmPKNIgAXN0RllPhOMDn0nss-rohiTyvwYOtqLQgnEd0BY6Nifk2O4h0&_nc_zt=23&_nc_ht=scontent-lga3-1.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQJEw5D-XUGzIDqfRvd3PYV2VHJbnaJgnBz6niOzlyw-Jw&oe=6AAB2BA6"
},
"animated_image_caption": null,
"broadcaster_origin": null,
"broadcast_id": null,
"broadcast_status": null,
"is_live_streaming": false,
"is_live_trace_enabled": false,
"is_looping": false,
"is_video_broadcast": false,
"is_podcast_video": false,
"loop_count": 0,
"is_spherical": false,
"is_spherical_enabled": true,
"unsupported_browser_message": null,
"pmv_metadata": null,
"latency_sensitive_config": null,
"live_playback_instrumentation_configs": null,
"is_ncsr": false,
"permalink_url": "https://www.facebook.com/reel/4394633517486098/",
"seo_web_crawler_lookaside_url": null,
"video_status_type": "OK",
"can_use_oz": true,
"min_quality_preference": null,
"spherical_video_fallback_urls": null,
"comet_video_player_nextgendash_availability": "AVAILABLE",
"videoDeliveryLegacyFields": {
"dash_manifest_url": "https://www.facebook.com/dash_mpd_debug.mpd?v=4394633517486098&dummy=.mpd",
"browser_native_sd_url": "https://video-lga3-2.xx.fbcdn.net/o1/v/t2/f2/m412/AQPnLuuqWCAlF2rQjRjOUp3m3VTZ2mKlfyx34GzbrXkpzS-7xlZIUcnpIF7ovflH8ZDoAUNMDVYUHPN2tp3L8DgkuT2Wj7KMnX_-4oRTag.mp4?_nc_cat=101&_nc_sid=8bf8fe&_nc_ht=video-lga3-2.xx.fbcdn.net&_nc_ohc=qJZTORkpUigQ7kNvwGyWZgR&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuMzYwLnN2ZV9zZCIsInhwdl9hc3NldF9pZCI6MjI5OTE0Mjg0NDIyMjk1MiwiYXNzZXRfYWdlX2RheXMiOjUxLCJ2aV91c2VjYXNlX2lkIjoxMDgyNSwiZHVyYXRpb25fcyI6ODYsInVybGdlbl9zb3VyY2UiOiJ3d3cifQ%3D%3D&ccb=17-1&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQI1fcGOS6mP_a9FADMRZvzrbLZiuiq9eLmnIu6eBNS76Q&oe=6AAB2335&bitrate=330175&tag=sve_sd",
"browser_native_hd_url": "https://video-lga3-3.xx.fbcdn.net/o1/v/t2/f2/m412/AQOZ7_1RxOwju3Gc9BBoYUpXDjf4Nkm1yjDn_rRmpt5ZnY7T97JiqH1x6lvwhf-tRSC2DPnbfs9vqgzrQkBg0d2ido38yLimT-wnUWi8NL--8A.mp4?_nc_cat=104&_nc_sid=5e9851&_nc_ht=video-lga3-3.xx.fbcdn.net&_nc_ohc=424Sgs3YgSUQ7kNvwHrS5PP&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuMTA4MC5kYXNoX2gyNjQtYmFzaWMtZ2VuMl8xMDgwcCIsInhwdl9hc3NldF9pZCI6MjI5OTE0Mjg0NDIyMjk1MiwiYXNzZXRfYWdlX2RheXMiOjUxLCJ2aV91c2VjYXNlX2lkIjoxMDgyNSwiZHVyYXRpb25fcyI6ODYsInVybGdlbl9zb3VyY2UiOiJ3d3cifQ%3D%3D&ccb=17-1&vs=3ddc452b425a3aff&_nc_vs=HBksFQIYRWZiX3Blcm1hbmVudC84MjQ2QTE0NzlGREY0ODM4NjlGQ0VFOEM1NDFDMzg5Nl9tdF8xX3ZpZGVvX2Rhc2hpbml0Lm1wNBUAAsgBEgAVAhhAZmJfcGVybWFuZW50LzQ0NDI5MDM3RkQ5MTBDQjI4RjM2OTM2NjRBQjJCOUIzX2F1ZGlvX2Rhc2hpbml0Lm1wNBUCAsgBEgAoABgAGwKIB3VzZV9vaWwBMRJwcm9ncmVzc2l2ZV9yZWNpcGUBMRUAACbQt6SI3cOVCBUCKAJDMywXQFWO6XjU_fQYGmRhc2hfaDI2NC1iYXNpYy1nZW4yXzEwODBwEQB1AmWSqQEA&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQKp8x6TpgEMgdbsufe7OmIT84YLKpqsrYrg7-wWdSxtTw&oe=6AAB2760&bitrate=2180060&tag=dash_h264-basic-gen2_1080p",
"id": "4394633517486098"
},
"is_gaming_video": false,
"is_latency_menu_enabled": false,
"fbls_tier": null,
"is_latency_sensitive_broadcast": false,
"video_player_shaka_performance_logger_should_sample": false,
"autoplay_gating_result": "gatekeeper",
"viewer_autoplay_setting": "default_autoplay",
"can_autoplay": false,
"drm_info": "{\"video_license_uri_map\":{},\"graph_api_video_license_uri\":null,\"fairplay_cert\":null,\"widevine_cert\":\"CsECCAMSEBcFuRfMEgSGiwYzOi93KowYgrSCkgUijgIwggEKAoIBAQCZ7Vs7Mn2rXiTvw7YqlbWYUgrVvMs3UD4GRbgU2Ha430BRBEGtjOOtsRu4jE5yWl5KngeVKR1YWEAjp+GvDjipEnk5MAhhC28VjIeMfiG\\/+\\/7qd+EBnh5XgeikX0YmPRTmDoBYqGB63OBPrIRXsTeo1nzN6zNwXZg6IftO7L1KEMpHSQykfqpdQ4IY3brxyt4zkvE9b\\/tkQv0x4b9AsMYE0cS6TJUgpL+X7r1gkpr87vVbuvVk4tDnbNfFXHOggrmWEguDWe3OJHBwgmgNb2fG2CxKxfMTRJCnTuw3r0svAQxZ6ChD4lgvC2ufXbD8Xm7fZPvTCLRxG88SUAGcn1oJAgMBAAE6FGxpY2Vuc2Uud2lkZXZpbmUuY29tEoADrjRzFLWoNSl\\/JxOI+3u4y1J30kmCPN3R2jC5MzlRHrPMveoEuUS5J8EhNG79verJ1BORfm7BdqEEOEYKUDvBlSubpOTOD8S\\/wgqYCKqvS\\/zRnB3PzfV0zKwo0bQQQWz53ogEMBy9szTK\\/NDUCXhCOmQuVGE98K\\/PlspKkknYVeQrOnA+8XZ\\/apvTbWv4K+drvwy6T95Z0qvMdv62Qke4XEMfvKUiZrYZ\\/DaXlUP8qcu9u\\/r6DhpV51Wjx7zmVflkb1gquc9wqgi5efhn9joLK3\\/bNixbxOzVVdhbyqnFk8ODyFfUnaq3fkC3hR3f0kmYgI41sljnXXjqwMoW9wRzBMINk+3k6P8cbxfmJD4\\/Paj8FwmHDsRfuoI6Jj8M76H3CTsZCZKDJjM3BQQ6Kb2m+bQ0LMjfVDyxoRgvfF\\/\\/M\\/EEkPrKWyU2C3YBXpxaBquO4C8A0ujVmGEEqsxN1HX9lu6c5OMm8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0\"}",
"p2p_settings": null,
"audio_settings": null,
"captions_settings": null,
"broadcast_low_latency_config": null,
"audio_availability": "AVAILABLE",
"muted_segments": [],
"spherical_video_renderer": null,
"preferred_thumbnail": {
"image": {
"uri": "https://scontent-lga3-1.xx.fbcdn.net/v/t15.5256-10/753306317_2059899598252019_7051428696037703086_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=110&ccb=1-7&_nc_sid=50ce42&_nc_ohc=fFK4qXTnhggQ7kNvwFoTF67&_nc_oc=Adpv6Qoge3bNmPKNIgAXN0RllPhOMDn0nss-rohiTyvwYOtqLQgnEd0BY6Nifk2O4h0&_nc_zt=23&_nc_ht=scontent-lga3-1.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQJEw5D-XUGzIDqfRvd3PYV2VHJbnaJgnBz6niOzlyw-Jw&oe=6AAB2BA6"
},
"id": "2299143310889572"
},
"video_imf_data": null,
"playback_language_on_comet": null,
"warning_screen_renderer": null,
"cix_screen": null
},
"video_owner": {
"__typename": "User",
"id": "100076751910580",
"__isActor": "User",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof",
"displayPicture": {
"uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s40x40&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C"
},
"should_show_reels_follow_back_cta": false,
"subscribe_status": "CANNOT_SUBSCRIBE",
"delegate_page": {
"id": "103285455517243",
"uri_token": "thespeechprof",
"woodhenge_creator_info": {
"viewer_entrypoint_eligibility": {
"is_eligible": true
},
"id": "272843125228141:FubsCreatorInfo",
"viewer_active_subscription_entitlement": null
}
}
},
"fb_shorts_reshare_context": {
"is_reshare": false,
"reshare_creator": {
"__typename": "User",
"__isActor": "User",
"id": "100076751910580",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof"
}
},
"remix_info": {
"is_remixable": true,
"status": "DISABLED"
},
"video_owner_type": "FACEBOOK_USER",
"soundtrack_info": {
"id": "27856252440654104",
"type": "CUSTOM_AUDIO"
},
"track_title": "SpeechProf · Original audio",
"music_album_art_uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s64x64&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C",
"is_original_audio_on_facebook": true
},
"id": "UzpfSTEwMDA3Njc1MTkxMDU4MDpWSzo0Mzk0NjMzNTE3NDg2MDk4",
"sponsored_data": null,
"brand_safety_data": null,
"post_id": "1067862332448821",
"creation_time": 1784756186,
"unpublished_content_type": "PUBLISHED",
"scheduled_publish_time": null,
"sponsor_relationship": 0,
"branded_content_post_info": null,
"creator_collaboration": null,
"media_attribution_elements": [],
"attachments": [
{
"media": {
"__typename": "Video",
"is_made_with_meta_ai_app": false,
"__isNode": "Video",
"id": "4394633517486098",
"video_delivery_response": {
"transcript_urls": [],
"id": "4394633517486098"
}
},
"call_to_action_renderer": null
}
],
"can_viewer_delete": false,
"can_viewer_edit": false,
"can_viewer_cancel_collaboration_invite": false,
"can_viewer_remove_collaborator": false,
"can_viewer_see_collaboration_invite": false,
"legal_reporting_cta_type": null,
"legal_reporting_uri": null,
"to": null,
"video": {
"id": "4394633517486098",
"owner": {
"__typename": "User",
"id": "100076751910580"
}
},
"post_collaboration": null,
"can_viewer_remove_self_as_collaborator": false,
"if_viewer_can_see_stars_toggle_menu_option": null,
"transparency_ad_info": {
"menu_label": "Ad info",
"should_display_ad_info": false
},
"debug_info": null,
"inform_treatment_for_community_notes": null,
"message": {
"text": "Love you Ryan & Craig and sorry for any future confusion 🤣",
"ranges": [
{
"offset": 9,
"length": 12,
"entity": {
"__typename": "User",
"__isEntity": "User",
"mobileUrl": "https://m.facebook.com/StorytimewithRyanandCraig/",
"url": "https://www.facebook.com/StorytimewithRyanandCraig",
"__isNode": "User",
"id": "100064615544947"
}
}
]
},
"translated_message_for_viewer": null,
"contextualElementShortFormContext": {
"video_label": null
},
"__isNode": "Story"
},
"cursor": "1",
"id": "UzpfSTEwMDA3Njc1MTkxMDU4MDpWSzo0Mzk0NjMzNTE3NDg2MDk4"
}
],
"page_info": {
"end_cursor": "5",
"has_next_page": false
}
}
},
"qp_viewer": {
"eligible_promotions": {
"nodes": []
}
}
}
},
"sequence_number": 0
}
}
]
],
[
"RequireDeferredReference",
"unblock",
[],
[
[
"useVideoPlayerShakaPerformanceLoggerRelayImpl_init$normalization.graphql",
"VideoPlayerShakaPerformanceLogger",
"useVideoPlayerShakaPerformanceLoggerBuilder_init$normalization.graphql",
"VideoPlayerShakaPerformanceLoggerBuilder"
],
"sd"
]
],
[
"RequireDeferredReference",
"unblock",
[],
[
[
"useVideoPlayerShakaPerformanceLoggerRelayImpl_init$normalization.graphql",
"VideoPlayerShakaPerformanceLogger",
"useVideoPlayerShakaPerformanceLoggerBuilder_init$normalization.graphql",
"VideoPlayerShakaPerformanceLoggerBuilder"
],
"css"
]
],
[
"CometResourceScheduler",
"registerHighPriHashes",
null,
[
[
"yFGOD2w",
"eZENHpr",
"c7oHeza",
"pvDSJnA",
"tWsD7LM",
"SG1FRQy"
]
]
]
],
"phd2_indexes": ":401,400,402,403"
}
},
{
"__bbox": null
},
{
"__bbox": null
}
]
]
]
}
]
File diff suppressed because it is too large Load Diff
-44
View File
@@ -1,44 +0,0 @@
{
"__typename": "XIGPolarisVideoMedia",
"pk": "3983940149641276516",
"code": "DdJzHVRyOxk",
"media_type": 2,
"product_type": "clips",
"caption": {
"text": "I just quit my job at the BPAF and things are not looking good you guys"
},
"accessibility_caption": "Video by Vinny Thomas on September 11, 2026.",
"original_height": 1280,
"original_width": 720,
"video_duration": 60.486,
"image_versions2": {
"candidates": [
{
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/806299489_18624728518024548_8282126995775909321_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=109&ig_cache_key=abc.3-ccb7-5&efg=cover_frame&width=720",
"height": 1280,
"width": 720
},
{
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/806299489_18624728518024548_8282126995775909321_n.jpg?stp=dst-jpg_e15_s640x640_tt6&_nc_cat=109&ig_cache_key=abc.3-ccb7-5&efg=cover_frame&width=640",
"height": 640,
"width": 640
},
{
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/806299489_18624728518024548_8282126995775909321_n.jpg?stp=dst-jpg_e15_s320x320_tt6&_nc_cat=109&ig_cache_key=abc.3-ccb7-5&efg=cover_frame&width=320",
"height": 320,
"width": 320
}
]
},
"has_audio": true,
"video_versions": [
{
"type": 101,
"url": "https://scontent-lga3-3.cdninstagram.com/o1/v/t2/f2/m86/AQOsyEB80vwVJZ1X8Ang509fDOvIQYYT5Sw8ILSanSG2u_RQcsqL4IXxAs-sjup.mp4?_nc_cat=102&_nc_sid=5e9851&efg=xpv_progressive_720&width=720"
},
{
"type": 102,
"url": "https://scontent-lga3-3.cdninstagram.com/o1/v/t2/f2/m86/AQOsyEB80vwVJZ1X8Ang509fDOvIQYYT5Sw8ILSanSG2u_RQcsqL4IXxAs-sjup.mp4?_nc_cat=102&_nc_sid=5e9851&efg=xpv_progressive_480&width=480"
}
]
}
-19
View File
@@ -1,7 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test'; import { test } from 'node:test';
import { fromGraph } from '../src/platforms/instagram.ts'; import { fromGraph } from '../src/platforms/instagram.ts';
import { findMetaMedia, mediaFromMetaNode } from '../src/platforms/meta-media.ts';
import { fixture } from './helpers.ts'; import { fixture } from './helpers.ts';
test('a carousel becomes one media entry per child, in order', () => { test('a carousel becomes one media entry per child, in order', () => {
@@ -27,24 +26,6 @@ test('a reel becomes a progressive video with its cover frame as the poster', ()
assert.ok(item?.width && item.height, 'expected dimensions for the aspect ratio'); assert.ok(item?.width && item.height, 'expected dimensions for the aspect ratio');
}); });
test('a reel whose embed only ships a cover frame still finds its video on the post page', () => {
// The captioned embed stopped shipping a reel's `video_url` and never draws
// a logged-out `<video>`, so both payload and DOM hand back the poster only.
// The post page still carries the progressive file in `video_versions`
// (the meta-media schema), which is where the adapter now falls through to.
const node = findMetaMedia(fixture('instagram/reel-video-versions.json'));
assert.ok(node, 'expected to find the media node in the post payload');
const media = mediaFromMetaNode(node!, 'https://www.instagram.com/');
assert.equal(media.length, 1);
const [item] = media;
assert.equal(item?.kind, 'video', 'a reel is a video, not its cover frame');
assert.match(item?.url ?? '', /^https:\/\/.*\.mp4/);
assert.ok(!item?.url.includes('.m3u8'), 'the native player needs a real file, not HLS');
// The cover frame becomes the poster, not the media itself.
assert.ok(item?.kind === 'video' && item.poster?.url.includes('t51.82787-15'), 'expected the cover frame as poster');
assert.ok(item?.width === 720 && item.height === 1280, 'expected dimensions for the aspect ratio');
});
test('captions keep their line breaks', () => { test('captions keep their line breaks', () => {
const scraped = fromGraph(fixture('instagram/carousel.json')); const scraped = fromGraph(fixture('instagram/carousel.json'));
assert.ok(scraped.text?.includes('\n'), 'expected the caption to keep paragraph breaks'); assert.ok(scraped.text?.includes('\n'), 'expected the caption to keep paragraph breaks');
+1 -25
View File
@@ -1,11 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test'; import { test } from 'node:test';
import { import { findCaption, findMetaMedia, mediaFromMetaNode } from '../src/platforms/meta-media.ts';
findCaption,
findMetaMedia,
mediaFromMetaNode,
mediaFromOpenGraph,
} from '../src/platforms/meta-media.ts';
import { fixture } from './helpers.ts'; import { fixture } from './helpers.ts';
const REFERER = 'https://www.threads.com/'; const REFERER = 'https://www.threads.com/';
@@ -73,22 +68,3 @@ 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: { text: '' } } }), undefined);
assert.equal(findCaption({ x: { caption: null } }), 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), []);
});