Kydoimosandanthropic/claude-opus-4-8 f968ac749c
CI / Typecheck, test, build (pull_request) Successful in 48s
Fall through to the post page when a reel embeds only its cover frame
The captioned embed stopped shipping a reel's video_url and never draws a
logged-out <video>, so both the payload and the DOM hand back the cover
frame alone. Because that still counts as media, the post-page fallback
never fired and a reel resolved to a still image.

Trigger the fallback when the URL is a reel (or the payload declared a
video) but no video was found, so it reaches the post page, whose payload
still carries the progressive file in video_versions. The embed's
caption, handle and avatar are carried forward, since the post-page
payload does not include the owner.

Co-authored-by: anthropic/claude-opus-4-8
2026-09-16 20:54:39 -03:00
2026-09-12 15:06:53 +00:00
2026-09-12 11:35:32 -03:00

antisocial

Reads social posts back to you without the app.

Links to X, Threads, Instagram, Facebook, TikTok, Bluesky and Reddit get shared constantly, and opening one means an app interstitial, a login wall, a feed you didn't ask for, and a pile of tracking. antisocial is the other half of a StopTheMadness rewrite rule: the link gets redirected here, and you get the post — the media and the words — plus a badge saying where it came from and a button to copy the original URL if you do want to go there.

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 can reach. It resolves posts by driving a real headless browser, logged out, exactly as if you had opened the link yourself.

StopTheMadness rules

One redirect rule per platform. The host is swapped for antisocial plus a short platform segment; the rest of the path is left alone, so the original is always recoverable and readable in your history.

Replace antisocial.example.com with wherever you are running it.

Each rule is two fields. Both are on their own line below, and neither needs any escaping — copy them straight out of this file.

# X
/^https:\/\/(?:www\.|mobile\.)?(?:x|twitter)\.com\/(.*)$/
https://antisocial.example.com/x/$1

# Threads
/^https:\/\/(?:www\.)?threads\.(?:net|com)\/(.*)$/
https://antisocial.example.com/threads/$1

# Instagram
/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/
https://antisocial.example.com/ig/$1

# Facebook
/^https:\/\/(?:www\.|m\.|web\.)?facebook\.com\/(.*)$/
https://antisocial.example.com/fb/$1

# TikTok
/^https:\/\/(?:www\.|vm\.|vt\.)?tiktok\.com\/(.*)$/
https://antisocial.example.com/tiktok/$1

# Bluesky
/^https:\/\/bsky\.app\/(.*)$/
https://antisocial.example.com/bsky/$1

# Reddit
/^https:\/\/(?:www\.|old\.|new\.|np\.|m\.)?reddit\.com\/(.*)$/
https://antisocial.example.com/reddit/$1

# Reddit short links
/^https:\/\/redd\.it\/(.*)$/
https://antisocial.example.com/reddit/$1

A code block rather than a table, because a table cell cannot hold a bare | — it has to be written \|, which renders correctly and copies wrongly. The alternation in these rules is full of them, and a regex whose pipes arrive as literal pipes matches nothing and says nothing about why.

So https://x.com/user/status/123 becomes https://antisocial.example.com/x/user/status/123.

Tracking parameters (igsh, utm_*, share_id, s, t, and friends) are stripped on arrival, so the URL the copy button gives back is the clean one. TikTok vm./vt. 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> 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 button hands back. A Facebook /share/r/<code> link is followed the same way, and 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 them from there.

Opening the original

The rules are indiscriminate, which is the point — but that catches the way back out too. Tapping "Open on Instagram" in the browser the rules are installed in redirects straight back here, so the one button meant to get you to the app is the one button that cannot.

The way past it is to hand the address to a different browser, by its own URL scheme. / has a picker for which one:

Browser macOS iOS
Chrome googlechromes://… googlechromes://…
Edge microsoft-edge:https://… microsoft-edge-https://…
Orion orion://open-url?url=… orion://open-url?url=…
Firefox firefox://open-url?url=…

Firefox on macOS registers no scheme, so it is not offered there; nor is Safari itself, which is the browser being escaped. The choice is kept in that browser's own localStorage — not a cookie and not synced, because "which browser is installed" is a fact about the device, not about you, and the phone's answer is not the Mac's.

Until a browser is chosen there is no "Open on …" button at all — the plain address is the one thing it must not offer, since following it only comes back here. The markup carries that address anyway, hidden, and the script swaps in the scheme and reveals the button once there is somewhere to send it. So with JavaScript off, or with the default left alone, the copy button and the selectable URL are the whole of it, which is all that was ever load-bearing.

How it works

Every request drives a real Chromium page load. One code path, and it survives markup changes better than parsing HTML from the outside would.

Each adapter layers its extraction, most structured first:

  1. The platform's own API response, caught as it goes past during the page load. Reading the JSON a platform serves its own front end beats scraping what it renders.
  2. An inline payload in the page — a JSON script tag, or an object buried in a bootstrap call (src/platforms/scan.ts pulls a balanced object out by key, including when it arrives escaped inside a JS string, which is what Instagram does).
  3. The rendered DOM — whatever is actually on screen is real.
  4. Open Graph tags — the floor, and enough to show something.
Platform Loads Reads
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
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__
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

On Bluesky and Threads people write in chains, so where the linked post is part of one, the author's own follow-ups are shown with it, in the order they were written, with the post you actually followed marked. Other people's replies are left out — they are a conversation, not the thing that was shared, and on a busy post there are hundreds of them.

A post that quotes another shows both, on X and on Bluesky. The quoted post gets its own author, its own words and its own pictures, in a block inside the one quoting it — because half the quote posts people share are someone answering a stranger and the other half are someone continuing a thought from an earlier post, and neither reads with only one side of it on the page. Showing the quoted picture on its own, which is what used to happen, put it under the wrong person's name.

X links are un-shortened. Every link in a post is rewritten to a t.co before it is stored, so left alone the page shows t.co/QdJhOVu4En and sends you through X's click tracker to find out where it goes; the real address is in the payload alongside. The shortlink X staples onto the end of a quote post is dropped rather than expanded, since the post it points at is already on the page — a link the author put there on purpose is kept.

On Reddit the thread under the post is usually the reason the link was shared, so Reddit posts come with it: every comment the first page carried, nested the way it was written. Each comment is a <details> element, so folding one takes its whole subtree with it, works without JavaScript and works from the keyboard; a collapsed comment says how many replies it is hiding. "Collapse all" is the one piece that needs the script, which is why it only appears once the script has run. What was behind a load more is not fetched — that is a second page and often a third — but it is counted and said out loud rather than quietly dropped.

Pictures inside comments are shown as pictures. Reddit writes them as a token rather than an address — a Giphy id, a subreddit emote, or an image uploaded to the comment — and all three are looked up in the comment's own metadata to find the real file. An image address someone simply pasted is shown too, which on Reddit is how most of them arrive. All of it goes through the same /m/ proxy as everything else, so reading a comment thread never has your browser talking to Reddit.

Media never gets linked straight at a CDN. Instagram and TikTok reject requests without a matching Referer (and sometimes cookies), and proxying keeps your browser from talking to the platform at all. Every asset is registered under an opaque /m/<id> and streamed through, with Range forwarded so the native video scrubber can seek.

The one exception is Bluesky video, which is an HLS playlist — proxying it would mean rewriting the manifest and every segment, so it is linked directly. Safari plays HLS natively; other browsers show a note.

A page carrying a video asks iOS for the playback audio session. Without it a video playing inline is treated as ambience and the Ring/Silent switch mutes it, so the post plays perfectly and says nothing unless you go fullscreen.

Resolved posts are cached in memory for an hour, so a reload or a back button doesn't drive the browser again.

When a platform breaks

It will. Someone else's markup is not a contract.

Start with the CLI, which drives the real browser and prints what came back:

npm run resolve -- 'https://www.tiktok.com/@user/video/123'

If that fails, ask the page what it actually served:

npm run probe -- 'https://www.instagram.com/reel/ABC123/embed/captioned/'

Then npm test, which runs every adapter against captured payloads in test/fixtures/ without touching the network. If the fixtures pass but the CLI fails, the shape changed upstream — capture a new payload and work from the diff.

A failed resolve is never a blank error page: the failure card carries the platform, the original URL and the copy button, so a broken adapter still leaves the link one tap away.

Known rough edges:

  • 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 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 the puzzle rather than an error — see below.
  • Reddit refuses .json to a browser it has never seen. The first request of a cold profile gets a JavaScript challenge, which the page solves by itself on an ordinary navigation; the adapter does that once and retries, and the cookie it leaves behind serves every later post. If the JSON is still refused, the rendered page is read instead — the same comments, without the scores Reddit is withholding.

Verification puzzles

TikTok will occasionally answer with a "drag the slider to fit the puzzle" challenge instead of the post. Rather than reporting that as a failure, antisocial gives you the puzzle to solve.

The page showing it stays open in the server's browser and gets parked. You are redirected to /challenge/<id>, which streams screenshots of just the puzzle and forwards your pointer events back to be replayed onto that page. Solve it, and you land back on the post.

What makes this work is that the cookie the challenge issues lands in the shared browser context, so it is not just this post that unblocks — the next few are fine too. And because the drag being replayed is your actual drag, at your actual timing, the movement looks like what it is.

Parked pages hold a browser tab but no concurrency permit, so one waiting on you never blocks anything else. At most three are kept, and each expires after five minutes.

There is a "give up" button, which closes the tab and leaves you the original link.

Login walls are a different thing and are not passed through: Instagram asking you to sign in cannot be solved without an account, so those degrade to whatever the post's link preview offers.

Development

Needs Node 22+. The container commands below use Apple container; docker takes the same arguments if that is what you have.

npm ci
npx playwright install chromium   # once
npm run dev                       # http://localhost:8080

npm test          # adapters against captured payloads, no network
npm run typecheck
npm run build

container build --tag antisocial:dev .
container run --rm --publish 8080:8080 antisocial:dev

There is no ESLint: TypeScript 7 is a year ahead of what typescript-eslint supports, and tsc with strict, noUnusedLocals and noUncheckedIndexedAccess covers the ground for a project this size.

Configuration

Everything has a working default; the container needs none of it set.

Variable Default
PORT / HOST 8080 / 0.0.0.0
PROFILE_DIR ./profile (/data/profile in the image) Chromium cookies and storage, persisted so the browser accumulates ordinary state
MAX_CONCURRENT 2 page loads at once; the rest queue
NAVIGATION_TIMEOUT_MS 20000
RESOLVE_TIMEOUT_MS 30000 whole resolve, including extraction
CACHE_TTL_MS / CACHE_MAX 3600000 / 200 resolved posts, in memory
MEDIA_TOKEN_TTL_MS 21600000 how long a /m/ reference stays valid
PUBLIC_ORIGIN http://localhost:8080 only used to print the rules on /
LOG_LEVEL info

Adding a platform

One file in src/platforms/, one entry in the table in src/platforms/index.ts. The adapter gets a Page and returns a Post (src/types.ts); everything downstream — proxying, rendering, caching, the error card — already knows what to do with one.

Set textPosition to 'above' for platforms that lead with words and 'below' for the ones that lead with pictures.

S
Description
A web app for viewing social posts without dickovers
Readme
808 KiB
Languages
TypeScript 87.7%
CSS 6%
JavaScript 5.5%
Dockerfile 0.8%