Initial commit: read social posts back without the app
antisocial is the other half of a StopTheMadness redirect rule. Links to X, Threads, Instagram, TikTok and Bluesky get rewritten to /<prefix>/<original path>, and this resolves the post and shows the media and the words, with a badge saying where it came from and a button to copy the original URL. Every request drives a real headless Chromium, logged out, from a residential IP. One code path, and it survives markup changes better than parsing from the outside would. Extraction is layered, most structured first: the platform's own API response caught in flight, then an inline payload, then the rendered DOM, then Open Graph tags. Media is never linked straight at a CDN. Instagram and TikTok reject requests without a matching Referer and cookies, and proxying keeps the viewer's browser from talking to the platform at all. Range is forwarded so the native video scrubber can seek. HLS is the exception, since proxying it would mean rewriting playlists. TikTok sometimes answers with a slider puzzle. Rather than reporting that as a failure, the page is parked and the viewer is handed the puzzle: screenshots stream out, pointer events are replayed back. Solving it leaves the cookie in the shared browser context, so the retry is an ordinary request. A failed resolve is never a blank error page. The card carries the platform, the original URL and the copy button, so a broken adapter still leaves the link one tap away. Verified end to end against real shared links on all five platforms, in the container, including multi-image carousels, reels, TikTok short links and photo posts. 49 tests run the adapters against captured payloads with no network. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
# 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.
|
||||
|
||||
Single user, no authentication, tailnet only. It resolves posts by driving a real
|
||||
headless browser from a residential IP, logged out, exactly as if you'd opened the link
|
||||
yourself.
|
||||
|
||||
Served at <https://antisocial.unsupervised.studio> from Kallone. Deployment manifests
|
||||
live in the `infra` repo under `k8s/kallone/antisocial.unsupervised.studio/`.
|
||||
|
||||
## 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.
|
||||
|
||||
| Platform | Find | Replace |
|
||||
| --- | --- | --- |
|
||||
| X | `^https://(?:www\.\|mobile\.)?(?:x\|twitter)\.com/(.*)$` | `https://antisocial.unsupervised.studio/x/$1` |
|
||||
| Threads | `^https://(?:www\.)?threads\.(?:net\|com)/(.*)$` | `https://antisocial.unsupervised.studio/threads/$1` |
|
||||
| Instagram | `^https://(?:www\.)?instagram\.com/(.*)$` | `https://antisocial.unsupervised.studio/ig/$1` |
|
||||
| TikTok | `^https://(?:www\.\|vm\.\|vt\.)?tiktok\.com/(.*)$` | `https://antisocial.unsupervised.studio/tiktok/$1` |
|
||||
| Bluesky | `^https://bsky\.app/(.*)$` | `https://antisocial.unsupervised.studio/bsky/$1` |
|
||||
|
||||
So `https://x.com/user/status/123` becomes
|
||||
`https://antisocial.unsupervised.studio/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+. Local container builds use Apple `container`, which is arm64 native and
|
||||
therefore the same architecture as Kallone.
|
||||
|
||||
```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` | `https://antisocial.unsupervised.studio` | 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.
|
||||
Reference in New Issue
Block a user