A shared Facebook link lands on a page that carries the whole post logged
out -- the caption, the author, the files, the dimensions -- in ScheduledServerJS
payloads that are plain JSON in ordinary script tags. What it does not carry
is only that post. A reel arrives with the next five reels of the feed
attached under viewer.lasso_blue_feed, a video with its related videos, and
every one of them has the same fields in the same shape as the real one.
Reading the first node with media on it gets a stranger's reel under someone
else's name, which is the same false attribution a lifted quote-post picture
used to be.
So nothing is read until the post has been picked out. partsOfPost matches the
id in the address against every node that names one; an address with no id in
it -- a pfbid permalink -- is matched on permalink_url instead. Only with
nothing to match on at all does it fall back to the route's query results,
which is still narrower than the whole payload: the page ships its entire
client configuration alongside the post, thousands of nodes carrying a name or
an id, and a plain search finds a video player setting long before it finds the
author.
An id that matches nothing is a failure rather than a best guess. Facebook
answers a link to something it no longer has by quietly serving something else
-- /watch/<id> for a video that is gone comes back as the Watch home page, feed
and all -- so the error card, which still carries the link and the copy button,
is the honest answer.
One post's pieces are spread across several payload blocks: a video post keeps
its files in one, its caption in another, its author's avatar in a third and
its timestamp in a fourth. Every claiming node is collected, not just the
first, and the author's gaps are filled only from nodes carrying the same id.
The author is whatever the payload calls the owner -- actors, owner,
video_owner, owner_as_page. `author` on a Facebook page means the author of a
comment, which sits right there in the same shape with a name and a picture of
its own.
Media is videoDeliveryLegacyFields.browser_native_hd_url with
preferred_thumbnail as its poster, photo_image or image for a picture, and
all_subattachments.nodes for a post of several -- which Facebook ships empty on
every single-picture post, so only a populated one is a carousel. The CDN is
signed and serves Range without asking for a referrer, but the assets are
proxied like everything else.
/share/{r,v,p,g} links are stubs, so the adapter follows one and hands back
where it landed: the share code says nothing about what it opens, and rdid,
share_url and fs are what the redirect leaves behind. m.facebook.com is a login
wall logged out, so the rewrite rule rebuilds on www.
Fixtures are real captures of a reel, a photo post and a video post, trimmed of
the DASH manifests and tracking blobs. The reel keeps its recommendations and
the video post keeps its comments, because those are the two things that have
to survive being read past.
Co-Authored-By: Claude Opus 5 <[email protected]>
19 KiB
antisocial — working notes
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
/<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.
Built for one person on a private network, with no authentication. README.md has the
rewrite rules and the user-facing description. This file is the working context.
This repository is public and deliberately says nothing about where any particular instance runs. Keep deployment specifics — hostnames, clusters, registries, manifests — out of it; they belong in whatever private repo does the deploying. Configuration is read from the environment, and CI reads the registry from repository variables.
The main job: "this link didn't work"
That is the recurring task. Work it in this order and do not skip step 1.
1. Reproduce against the real browser.
npm run resolve -- 'https://www.instagram.com/user/reel/ABC123/'
Prints the resolved Post as JSON, or the error. This is the ground truth.
2. Decide what kind of failure it is. These are genuinely different and get different fixes:
| Symptom | What it usually is |
|---|---|
| Works now, failed before | Rate limiting. TikTok especially. Not a bug — verify by waiting and retrying before changing anything. |
| Verification puzzle | Expected. Should redirect to /challenge/<id>. If it reports an error instead, the detection raced the render. |
| 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. |
| 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. |
3. Look at what the page actually served.
npm run probe -- 'https://www.instagram.com/reel/ABC123/embed/captioned/' 5000
Pass the URL the adapter loads, not the post URL — for Instagram that is the
/embed/captioned/ form. It reports which payload keys are present, the DOM's
<video>/<img>, the OG tags, whether a challenge is up, and the API traffic with
bundle noise filtered out. Every adapter fix so far started here.
4. Fix the adapter, then capture a fixture for the case that broke, and assert on it
in test/<platform>.test.ts. Fixtures are real captured payloads — see the ones in
test/fixtures/. A fix without a fixture will silently regress.
5. Re-run the whole suite (npm test), and re-resolve the link end to end.
Architecture, briefly
Request → src/routes/post.ts → src/platforms/index.ts maps prefix to adapter →
src/resolve.ts (cache + timeout + concurrency gate) → withPage from
src/browser/pool.ts → the adapter → a Post → src/render/post.ts.
Adding a platform is one file in src/platforms/ plus one row in the table in
index.ts. Everything downstream already handles a Post.
A Post is a list of Segments, not a single body. Most platforms produce one
(oneSegment in types.ts); Bluesky and Threads produce the author's whole chain,
with isAnchor marking the post that was linked — which need not be the first.
Segment.quoted is a post inside a post — someone else's words and pictures, kept
under their own name. X and Bluesky fill it in. Both used to lift the quoted post's
media out and show it as the quoter's own, which is the bug to avoid reintroducing:
a quoted picture without the name attached to it is a false attribution.
Post.comments is a separate thing and a tree, not a chain. Only Reddit fills it in,
because only there is the conversation usually the point of the link. It is a tree
rather than a flat list with depths because folding a comment has to take everything
under it along, and nesting is what makes that free — the renderer emits a <details>
per comment and gets collapsing without a line of JavaScript.
Things worth knowing before editing:
- One Chromium, one context, persistent. Cookies and dismissed banners accumulate on
purpose — that is what makes traffic look like ordinary browsing.
MAX_CONCURRENTpage loads, the rest queue. - Images, video and fonts are aborted at the route layer. The URL is all we need; the
bytes get fetched later through our own proxy. Aborting does not remove
srcattributes, so DOM extraction still works. - 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 when you touch an adapter; each layer is the fallback for the one above.
mediaFromOpenGraphinmeta-media.tsis 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.tspulls a balanced JSON object out of a page by key, 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.- Media is never linked straight at a CDN. Assets are registered in
src/media/registry.tsand served from/m/<id>with theReferer/Cookiethe CDN demands.Rangeis forwarded — without it the native video scrubber cannot seek. The exception is HLS (direct: true), because proxying would mean rewriting playlists. - A video is sized by its poster, not by the ratio the renderer puts on it. A
<video>with no data has a natural size of 300x150, and WebKit sizes it from that — so one with no poster of its own gets an empty SVG of the right shape as a stand-in, without which a portrait video sits in a squat landscape box until you press play. - A page with a video claims the
playbackaudio session (public/app.js). iOS hands inline video the ambient session, which the Ring/Silent switch mutes, so without this the video plays and says nothing until it goes fullscreen — and the report that arrives is "this one platform has no sound". The session only activates once something plays, so declaring it costs nothing on a page nobody presses play on. - Never a bare error page. A failed resolve renders a card carrying the platform, the original URL and the copy button. A broken adapter must still leave the link one tap away.
Per-platform notes
- Bluesky — asks the public API directly (still through the browser context), so it
is the most reliable. The web app calls
getPostThreadV2now; the page fallback deliberately matches only V1. Threads are built by walkingparentup and the author's ownrepliesdown;depth/parentHeightare what make that possible, at the cost of dragging the whole reply tree along (a few hundred KB on a busy post). A quote fills inSegment.quoted. The record sits atembed.recordfor a plain quote and atembed.record.recordwhen the quoting post has media of its own (recordWithMedia) — check both. A quote can also point at a feed, a list or a post since deleted, which arrive in the same slot under a different$type, so onlyapp.bsky.embed.record#viewRecordis accepted. The quoted post's address has to be rebuilt:uriis anat://nobody can open. - X — the
platform.twitter.comembed calls the syndication endpoint; we catch that response. A quote post keeps the post it quotes whole, inSegment.quoted— author, words and pictures — rather than lifting its media out; doing that put someone else's picture under the quoter's name, and dropped the quoted words entirely. There is no permalink in the payload, so the quoted post's URL is rebuilt from the handle andid_str. Two things about the text: it arrives pre-escaped (&) and has to be decoded, since everything downstream escapes again on the way out; and X staples at.coto the quoted post onto the end, whichdisplay_text_rangetrims — its indices are UTF-16 units into the escaped text, so slice before decoding and do not split to codepoints first. Every remaining link is at.co, expanded fromentities.urls— matched on the shortlink text rather than byindices, which are offsets into a string the expansion is changing the length of. - Instagram — the least reliable. It ships the structured payload only some of the
time, and refuses some posts from the embed entirely ("the link may be broken"). The
DOM is the floor and only ever shows the first carousel item. Reels hydrate their
<video>roughly 0.6–1.3s afterdomcontentloaded, so the DOM read waits when a video is expected. - TikTok — the post lives under whichever
__UNIVERSAL_DATA_FOR_REHYDRATION__scope carries anitemStruct; do not hardcode the key, photo posts use a different one. Its CDN needsRefererand cookies. Shortvm./vt.links arrive as a bare path segment and are rebuilt inbuildOriginalUrl. - Reddit — the
.jsontwin of any post URL is the whole post plus the first page of comments in one response, which is far better than anything the page gives up, so that is the only layer that normally runs. A cold profile gets a JavaScript challenge instead of JSON; an ordinary navigation solves it by itself, so the adapter navigates once and retries, and the cookie serves every later post. The floor isshreddit-commentelements, which are flat with adepthattribute —treeFromDepthsrebuilds the nesting.repliesis""and not an object when there are none. Video:fallback_urlis the video track alone wheneverhas_audiois true, so a post with sound has to usehls_url, direct and unproxied; a silent one gets the proxied MP4.scrubber_media_urlis not a poster — it is a second MP4 for the timeline thumbnails, and the still is inpreview.images— which belongs to both forms: the HLS one used to go without, and showed an empty box where a silent post showed a frame. A gallery's pictures are inmedia_metadata, keyed and unordered; their order is only ingallery_data. Comment bodies are Markdown, rendered bysrc/render/markdown.ts— escape first, then put back the constructs we chose to support, neverbody_html. An image in a comment is written as a token rather than an address —,,— and the token is usually a key in that same comment's ownmedia_metadata, soresolveInlineImagesis one lookup rather than three special cases. Usually: plenty of comments carry a Giphy token and nomedia_metadataat all, and Giphy is the one of the three whose id means something off Reddit, so that token alone falls back toi.giphy.com/media/<id>/giphy.gif. A variant name after the id is dropped — Giphy does not serve every variant of every gif. A barepreview.redd.itaddress pasted into a comment is in there too, keyed by the id inside the URL. Prefers.gifovers.mp4for an animated one: a GIF moves in an<img>and an MP4 needs a player. An address 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 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
ScheduledServerJSpayloads 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 underviewer.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, whichpartsOfPostdoes by matching the id in the address against every node that names one. An address with no id in it (apfbidpermalink) is matched onpermalink_url/wwwURLinstead; 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 anameor anid, 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);authoron 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_urlfor a video withpreferred_thumbnailas its poster,photo_image/imagefor a picture, andall_subattachments.nodesfor 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 servesRangewithout 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.comis a login wall logged out — always rebuild onwww.. - 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 candidates in it. The page ships the linked post, the author's follow-ups, other people's replies and unrelated recommendations all as flatthread_itemscontainers. A follow-up is the author replying to themselves, which is what separates it from a stranger's reply carrying the samereply_to_author. The first post of a chain replies to nothing, so it is only reachable by walking backwards from the one that answers it.
Verification puzzles
TikTok challenges sometimes. The page is parked (src/challenge/registry.ts), kept open
but outside the pool holding no concurrency permit, and the viewer is redirected to
/challenge/<id>, which streams screenshots of the puzzle and replays their pointer
events onto it. Solving it deposits the cookie in the shared context, so the retry is an
ordinary request.
Login walls are not passed through — those need an account and cannot be solved this way.
Conventions
- TypeScript 7, ESM,
.tsimport specifiers (tsc rewrites them to.json emit). Node's type stripping runs the tree directly for dev, tests and the CLIs. - No ESLint —
typescript-eslintdoes not support TS 7 yet.npm run typecheckis the lint step. - Tests are
node:testagainst captured fixtures. No network in the test suite. public/is served as-is to the browser, so what is in there is plain JS, not TS.public/browsers.js— the per-browser URL schemes behind the "open in" picker — is a module rather than more ofapp.jsso a test can import it; that is whatallowJsintsconfig.jsonis for.- Comments explain why, especially where the code looks odd because a platform is odd. Match that; do not add narration of what the next line does.
- Post text comes from strangers: everything goes through the
htmltagged template orlinkify, both of which escape. Do not build markup by concatenation.
Commands
npm run resolve -- '<url>' # resolve one post, print JSON <- start here
npm run probe -- '<url>' # what the page actually served <- then here
npm test # fixtures, no network
npm run typecheck
npm run dev # http://localhost:8080
container build --tag antisocial:dev . # Apple container, not Docker
Publishing
.gitea/workflows/publish.yml builds the image and pushes it. A push to main publishes
:main and :sha-<short>; a version tag like 1.2.3 publishes :1.2.3, :1.2, :1
and :latest. A prerelease tag (1.2.3-rc1) publishes only its exact version and does
not move latest.
The registry comes from the REGISTRY repository variable, the image name from
IMAGE_NAME or the repository name, and credentials from REGISTRY_USER and the
REGISTRY_TOKEN secret. Nothing about any particular deployment is committed here.
A release also moves package.json on to the next patch version, opened as a pull
request by the bump job — so the number in the tree is never one that has already
shipped and been made immutable. It lives in publish.yml rather than a workflow of its
own so it can say needs: build: a version that failed to publish has not been
released, and bumping past it would claim otherwise. Prereleases are skipped, being
candidates for a version that has not shipped. The bump goes through npm version
rather than an edit in place, because the version is in the lockfile too, in more than
one place, and the two have to agree.
It arrives as a pull request rather than as a commit straight to main: pushing a branch
asks nothing of the task token beyond ordinary write access, so it does not matter what
protects main, and the changed package.json goes through a build before it lands.
Nothing pushes to main, so no [skip ci] is needed to stop the bump rebuilding the
image just published. The job is the only one that runs in a container (node:22, for
npm), and a job in a container is handed sh, not bash — hence the explicit
shell: bash, without which set -o pipefail fails the first line of the first step.
Two things any deployment has to get right, both learned the hard way:
- Chromium needs more than the default 64Mi
/dev/shmor it crashes. Mount a memory-backed volume of a few hundred Mi at/dev/shm. - The registry must be reachable without a proxy that caps request bodies. The image
has a layer well over 100MB; a proxy with a smaller limit fails the push partway
through with
413 Payload Too Large.
Measured around 620Mi resident with the browser up.