People write in chains on both, and a link into one arrives pointing at a single post out of several. Showing only that post loses the thing that was being said. Other people's replies are a different matter: they are a conversation rather than the thing that was shared, and on a busy post there are hundreds of them. A Post is now a list of Segments instead of one body. Most platforms produce exactly one and say so through oneSegment(); the two that thread produce the whole chain, with isAnchor marking the post that was actually linked, which need not be the first. Bluesky walks parent upward and the author's own replies downward, stopping at the first post by anyone else. That needs depth and parentHeight on getPostThread, which drags the entire reply tree along -- a few hundred KB on a popular post -- because there is no way to ask the API for one author's branch. Threads is harder to read. The page ships the linked post, the author's follow-ups, other people's replies and a pile of unrelated recommendations, all as flat thread_items containers with no nesting to go on. What separates a follow-up from a stranger's reply is that a follow-up is the author replying to themselves; a reply from someone else carries the same reply_to_author with a different name on it. The first post of a chain replies to nothing at all, so it is reachable only by walking backwards from the post that answers it -- a test caught that, when linking the second post of a thread returned just the one post. Fixtures for both are real captures. The Bluesky one keeps two of every level's outside replies rather than pruning them away, because a filter is only worth testing against the thing it is supposed to exclude. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
9.1 KiB
antisocial — working notes
A self-hosted page that shows a social post without the app. StopTheMadness rewrites
links to X, Threads, Instagram, TikTok and Bluesky 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. |
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.
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.
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. - 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). - X — the
platform.twitter.comembed calls the syndication endpoint; we catch that response. A quote post carries no media of its own, so the quoted post's media is used. - 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. - 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. - 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.
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.