Files
antisocial/README.md
T
thatguygriffandClaude Opus 5 60a9468875
CI / Typecheck, test, build (pull_request) Successful in 46s
Show the author's own chain on Bluesky and Threads
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
2026-08-26 17:16:02 -03:00

185 lines
10 KiB
Markdown

# antisocial
Reads social posts back to you without the app.
Links to X, Threads, Instagram, TikTok and Bluesky 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.
| Platform | Find | Replace |
| --------- | ------------------------------------------------------------- | ------------------------------------------- |
| 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` |
| TikTok | `/^https:\/\/(?:www\.\|vm\.\|vt\.)?tiktok\.com\/(.*)$/` | `https://antisocial.example.com/tiktok/$1` |
| Bluesky | `/^https:\/\/bsky\.app\/(.*)$/` | `https://antisocial.example.com/bsky/$1` |
So `https://x.com/user/status/123` becomes
`https://antisocial.example.com/x/user/status/123`.
Tracking parameters (`igsh`, `utm_*`, `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>/`.
`/` serves this table with the live hostnames, if you'd rather read it there.
## 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>` |
| TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
| Threads | the post page | the Relay payloads in `<script type="application/json">` |
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.
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.
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:
```sh
npm run resolve -- 'https://www.tiktok.com/@user/video/123'
```
If that fails, ask the page what it actually served:
```sh
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.
- **TikTok sometimes answers with a slider puzzle** instead of the post. You get handed
the puzzle rather than an error — see below.
## 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.
```sh
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.