179 lines
9.7 KiB
Markdown
179 lines
9.7 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">` |
|
|
|
|
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.
|