CI / Typecheck, test, build (pull_request) Successful in 12s
A TikTok post played perfectly and said nothing on an iPhone. The file was not the problem: every rendition TikTok offers for it carries a full AAC track, and the page as served measures loud audio in WebKit and Chromium alike, straight through the /m/ proxy. iOS is the problem. A video playing inline gets the "ambient" audio session, which the Ring/Silent switch mutes; only going fullscreen gets the sound back. Claiming "playback" says what is true of this page -- the sound is the point, not decoration -- and the switch stops applying. Declared only where there is a video, so an ordinary text post never claims it, and the session activates when something plays rather than on load, so it interrupts nothing on a page nobody presses play on. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01B3RUMiXa6eAW3okw9nB9NF
288 lines
15 KiB
Markdown
288 lines
15 KiB
Markdown
# antisocial
|
|
|
|
Reads social posts back to you without the app.
|
|
|
|
Links to X, Threads, Instagram, 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.
|
|
|
|
```text
|
|
# 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
|
|
|
|
# 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.
|
|
|
|
`/` 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>` |
|
|
| 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:
|
|
|
|
```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.
|
|
- **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.
|
|
|
|
```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.
|