CI / Typecheck, test, build (pull_request) Successful in 11s
A comment that was a picture rendered as either a link or, for a Giphy, the literal text ``. On r/aww that is most of the thread. Reddit writes an inline image as a token rather than an address, in three shapes: `` for a Giphy, `` for a subreddit emote, and `` for an image uploaded straight to the comment. The useful part is that all three tokens are keys in that same comment's own `media_metadata`, so this is one lookup and not three special cases. Nothing in the adapter has to know what Giphy is. The fourth shape is someone pasting the address of a picture, which on Reddit is how most images in comments actually arrive -- 117 of them against 21 Giphys in the sample I scanned. Those are shown as pictures too, decided by the file extension. A link that is not to an image stays a link. Animated ones take `s.gif` over `s.mp4` even though the MP4 is several times smaller: a GIF moves on its own in an `<img>`, and an MP4 would need a player element with autoplay, loop and muted set, for something the size of a postage stamp. Everything goes through the `/m/` proxy, like all other media. Without that a comment thread would have the reader's browser fetch dozens of files straight from Reddit, which is the one thing this whole app exists to avoid. Placing the image is the renderer's job, not the Markdown parser's, because the proxy is a render-time concern and markdown.ts knows nothing about it -- so it takes an optional `ImageRenderer` and, without one, an image stays a link exactly as before. The parser also learned `![...]` proper: the link rule was matching from the `[` and stranding the `!` as text. Verified on r/aww/comments/171dxph, which carries one Giphy and 77 pasted images: 35 render on the first page, all 35 load, all 35 through the proxy, no upstream address reaches the page, no token is left unresolved, and nothing overflows the column or scrolls the page sideways. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
230 lines
14 KiB
Markdown
230 lines
14 KiB
Markdown
# antisocial — working notes
|
||
|
||
A self-hosted page that shows a social post without the app. StopTheMadness rewrites
|
||
links to X, Threads, Instagram, TikTok, Bluesky and Reddit into
|
||
`/<prefix>/<original path>`; this resolves the post by driving a real headless Chromium
|
||
and renders the media, the text, a platform badge and a copy-the-original button.
|
||
|
||
Built for one person on a private network, with no authentication. `README.md` has the
|
||
rewrite rules and the user-facing description. This file is the working context.
|
||
|
||
This repository is public and deliberately says nothing about where any particular
|
||
instance runs. Keep deployment specifics — hostnames, clusters, registries, manifests —
|
||
out of it; they belong in whatever private repo does the deploying. Configuration is
|
||
read from the environment, and CI reads the registry from repository variables.
|
||
|
||
## The main job: "this link didn't work"
|
||
|
||
That is the recurring task. Work it in this order and do not skip step 1.
|
||
|
||
**1. Reproduce against the real browser.**
|
||
|
||
```sh
|
||
npm run resolve -- 'https://www.instagram.com/user/reel/ABC123/'
|
||
```
|
||
|
||
Prints the resolved `Post` as JSON, or the error. This is the ground truth.
|
||
|
||
**2. Decide what kind of failure it is.** These are genuinely different and get
|
||
different fixes:
|
||
|
||
| Symptom | What it usually is |
|
||
| --- | --- |
|
||
| Works now, failed before | Rate limiting. TikTok especially. Not a bug — verify by waiting and retrying before changing anything. |
|
||
| Verification puzzle | Expected. Should redirect to `/challenge/<id>`. If it reports an error instead, the detection raced the render. |
|
||
| Right media, wrong count | The structured payload was missed and it fell through to the DOM, which only shows the first carousel item. |
|
||
| Poster image where a video belongs | Same fall-through, plus the `<video>` had not hydrated when the DOM was read. |
|
||
| Nothing at all | The platform refused this specific post logged out, or a key moved. |
|
||
| Reddit post fine, no comments | The `.json` was refused and it fell through to the page, where the comment tree loads late. |
|
||
|
||
**3. Look at what the page actually served.**
|
||
|
||
```sh
|
||
npm run probe -- 'https://www.instagram.com/reel/ABC123/embed/captioned/' 5000
|
||
```
|
||
|
||
Pass the URL *the adapter loads*, not the post URL — for Instagram that is the
|
||
`/embed/captioned/` form. It reports which payload keys are present, the DOM's
|
||
`<video>`/`<img>`, the OG tags, whether a challenge is up, and the API traffic with
|
||
bundle noise filtered out. Every adapter fix so far started here.
|
||
|
||
**4. Fix the adapter, then capture a fixture** for the case that broke, and assert on it
|
||
in `test/<platform>.test.ts`. Fixtures are real captured payloads — see the ones in
|
||
`test/fixtures/`. A fix without a fixture will silently regress.
|
||
|
||
**5. Re-run the whole suite** (`npm test`), and re-resolve the link end to end.
|
||
|
||
## Architecture, briefly
|
||
|
||
Request → `src/routes/post.ts` → `src/platforms/index.ts` maps prefix to adapter →
|
||
`src/resolve.ts` (cache + timeout + concurrency gate) → `withPage` from
|
||
`src/browser/pool.ts` → the adapter → a `Post` → `src/render/post.ts`.
|
||
|
||
Adding a platform is one file in `src/platforms/` plus one row in the table in
|
||
`index.ts`. Everything downstream already handles a `Post`.
|
||
|
||
A `Post` is a list of `Segment`s, not a single body. Most platforms produce one
|
||
(`oneSegment` in `types.ts`); Bluesky and Threads produce the author's whole chain,
|
||
with `isAnchor` marking the post that was linked — which need not be the first.
|
||
|
||
`Segment.quoted` is a post inside a post — someone else's words and pictures, kept
|
||
under their own name. X and Bluesky fill it in. Both used to lift the quoted post's
|
||
media out and show it as the quoter's own, which is the bug to avoid reintroducing:
|
||
a quoted picture without the name attached to it is a false attribution.
|
||
|
||
`Post.comments` is a separate thing and a tree, not a chain. Only Reddit fills it in,
|
||
because only there is the conversation usually the point of the link. It is a tree
|
||
rather than a flat list with depths because folding a comment has to take everything
|
||
under it along, and nesting is what makes that free — the renderer emits a `<details>`
|
||
per comment and gets collapsing without a line of JavaScript.
|
||
|
||
Things worth knowing before editing:
|
||
|
||
- **One Chromium, one context, persistent.** Cookies and dismissed banners accumulate on
|
||
purpose — that is what makes traffic look like ordinary browsing. `MAX_CONCURRENT`
|
||
page loads, the rest queue.
|
||
- **Images, video and fonts are aborted** at the route layer. The URL is all we need; the
|
||
bytes get fetched later through our own proxy. Aborting does not remove `src`
|
||
attributes, so DOM extraction still works.
|
||
- **Extraction is layered**, most structured first: the platform's own API response
|
||
caught in flight → an inline payload → the rendered DOM → OG tags. Keep that order
|
||
when you touch an adapter; each layer is the fallback for the one above.
|
||
- **`src/platforms/scan.ts`** pulls a balanced JSON object out of a page by key,
|
||
including when it arrives escaped inside a JS string. Instagram needs this. If a
|
||
payload looks present but parses to nothing, suspect an extra encoding level.
|
||
- **Media is never linked straight at a CDN.** Assets are registered in
|
||
`src/media/registry.ts` and served from `/m/<id>` with the `Referer`/`Cookie` the CDN
|
||
demands. `Range` is forwarded — without it the native video scrubber cannot seek.
|
||
The exception is HLS (`direct: true`), because proxying would mean rewriting playlists.
|
||
- **Never a bare error page.** A failed resolve renders a card carrying the platform, the
|
||
original URL and the copy button. A broken adapter must still leave the link one tap
|
||
away.
|
||
|
||
## Per-platform notes
|
||
|
||
- **Bluesky** — asks the public API directly (still through the browser context), so it
|
||
is the most reliable. The web app calls `getPostThreadV2` now; the page fallback
|
||
deliberately matches only V1. Threads are built by walking `parent` up and the
|
||
author's own `replies` down; `depth`/`parentHeight` are what make that possible, at
|
||
the cost of dragging the whole reply tree along (a few hundred KB on a busy post).
|
||
A quote fills in `Segment.quoted`. The record sits at `embed.record` for a plain quote
|
||
and at `embed.record.record` when the quoting post has media of its own
|
||
(`recordWithMedia`) — check both. A quote can also point at a feed, a list or a post
|
||
since deleted, which arrive in the same slot under a different `$type`, so only
|
||
`app.bsky.embed.record#viewRecord` is accepted. The quoted post's address has to be
|
||
rebuilt: `uri` is an `at://` nobody can open.
|
||
- **X** — the `platform.twitter.com` embed calls the syndication endpoint; we catch that
|
||
response. A quote post keeps the post it quotes whole, in `Segment.quoted` — author,
|
||
words and pictures — rather than lifting its media out; doing that put someone else's
|
||
picture under the quoter's name, and dropped the quoted words entirely. There is no
|
||
permalink in the payload, so the quoted post's URL is rebuilt from the handle and
|
||
`id_str`. Two things about the text: it arrives pre-escaped (`&`) and has to be
|
||
decoded, since everything downstream escapes again on the way out; and X staples a
|
||
`t.co` to the quoted post onto the end, which `display_text_range` trims — its indices
|
||
are UTF-16 units into the *escaped* text, so slice before decoding and do not split to
|
||
codepoints first. Every remaining link is a `t.co`, expanded from `entities.urls` —
|
||
matched on the shortlink text rather than by `indices`, which are offsets into a string
|
||
the expansion is changing the length of.
|
||
- **Instagram** — the least reliable. It ships the structured payload only some of the
|
||
time, and refuses some posts from the embed entirely ("the link may be broken"). The
|
||
DOM is the floor and only ever shows the first carousel item. Reels hydrate their
|
||
`<video>` roughly 0.6–1.3s after `domcontentloaded`, so the DOM read waits when a video
|
||
is expected.
|
||
- **TikTok** — the post lives under whichever `__UNIVERSAL_DATA_FOR_REHYDRATION__` scope
|
||
carries an `itemStruct`; do not hardcode the key, photo posts use a different one.
|
||
Its CDN needs `Referer` *and* cookies. Short `vm.`/`vt.` links arrive as a bare path
|
||
segment and are rebuilt in `buildOriginalUrl`.
|
||
- **Reddit** — the `.json` twin of any post URL is the whole post plus the first page of
|
||
comments in one response, which is far better than anything the page gives up, so that
|
||
is the only layer that normally runs. A cold profile gets a JavaScript challenge
|
||
instead of JSON; an ordinary navigation solves it by itself, so the adapter navigates
|
||
once and retries, and the cookie serves every later post. The floor is
|
||
`shreddit-comment` elements, which are flat with a `depth` attribute — `treeFromDepths`
|
||
rebuilds the nesting. `replies` is `""` and not an object when there are none.
|
||
Video: `fallback_url` is the *video track alone* whenever `has_audio` is true, so a
|
||
post with sound has to use `hls_url`, direct and unproxied; a silent one gets the
|
||
proxied MP4. `scrubber_media_url` is not a poster — it is a second MP4 for the
|
||
timeline thumbnails, and the still is in `preview.images`. A gallery's pictures are in
|
||
`media_metadata`, keyed and unordered; their order is only in `gallery_data`. Comment
|
||
bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put
|
||
back the constructs we chose to support, never `body_html`. An image in a comment is
|
||
written as a token rather than an address — ``,
|
||
``, `` — and in every case the token is
|
||
a key in that same comment's own `media_metadata`, so `resolveInlineImages` is one
|
||
lookup rather than three special cases. A bare `preview.redd.it` address pasted into a
|
||
comment is in there too, keyed by the id inside the URL. Prefer `s.gif` over `s.mp4`
|
||
for an animated one: a GIF moves in an `<img>` and an MP4 needs a player.
|
||
- **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its
|
||
payloads are full of empty stub nodes, so the finder only accepts a node with actual
|
||
candidates in it. The page ships the linked post, the author's follow-ups, other
|
||
people's replies and unrelated recommendations all as flat `thread_items`
|
||
containers. A follow-up is the author replying to *themselves*, which is what
|
||
separates it from a stranger's reply carrying the same `reply_to_author`. The first
|
||
post of a chain replies to nothing, so it is only reachable by walking backwards
|
||
from the one that answers it.
|
||
|
||
## Verification puzzles
|
||
|
||
TikTok challenges sometimes. The page is parked (`src/challenge/registry.ts`), kept open
|
||
but outside the pool holding no concurrency permit, and the viewer is redirected to
|
||
`/challenge/<id>`, which streams screenshots of the puzzle and replays their pointer
|
||
events onto it. Solving it deposits the cookie in the shared context, so the retry is an
|
||
ordinary request.
|
||
|
||
Login walls are not passed through — those need an account and cannot be solved this way.
|
||
|
||
## Conventions
|
||
|
||
- TypeScript 7, ESM, `.ts` import specifiers (tsc rewrites them to `.js` on emit).
|
||
Node's type stripping runs the tree directly for dev, tests and the CLIs.
|
||
- **No ESLint** — `typescript-eslint` does not support TS 7 yet. `npm run typecheck` is
|
||
the lint step.
|
||
- Tests are `node:test` against captured fixtures. No network in the test suite.
|
||
- Comments explain *why*, especially where the code looks odd because a platform is odd.
|
||
Match that; do not add narration of what the next line does.
|
||
- Post text comes from strangers: everything goes through the `html` tagged template or
|
||
`linkify`, both of which escape. Do not build markup by concatenation.
|
||
|
||
## Commands
|
||
|
||
```sh
|
||
npm run resolve -- '<url>' # resolve one post, print JSON <- start here
|
||
npm run probe -- '<url>' # what the page actually served <- then here
|
||
npm test # fixtures, no network
|
||
npm run typecheck
|
||
npm run dev # http://localhost:8080
|
||
container build --tag antisocial:dev . # Apple container, not Docker
|
||
```
|
||
|
||
## Publishing
|
||
|
||
`.gitea/workflows/publish.yml` builds the image and pushes it. A push to main publishes
|
||
`:main` and `:sha-<short>`; a version tag like `1.2.3` publishes `:1.2.3`, `:1.2`, `:1`
|
||
and `:latest`. A prerelease tag (`1.2.3-rc1`) publishes only its exact version and does
|
||
not move `latest`.
|
||
|
||
The registry comes from the `REGISTRY` repository variable, the image name from
|
||
`IMAGE_NAME` or the repository name, and credentials from `REGISTRY_USER` and the
|
||
`REGISTRY_TOKEN` secret. Nothing about any particular deployment is committed here.
|
||
|
||
A release also moves `package.json` on to the next patch version, committed to main by
|
||
the `bump` job — so the number in the tree is never one that has already shipped and
|
||
been made immutable. It lives in `publish.yml` rather than a workflow of its own so it
|
||
can say `needs: build`: a version that failed to publish has not been released, and
|
||
bumping past it would claim otherwise. Prereleases are skipped, being candidates for a
|
||
version that has not shipped. The bump goes through `npm version` rather than an edit in
|
||
place, because the version is in the lockfile too, in more than one place, and the two
|
||
have to agree. The commit carries `[skip ci]`, or pushing it would rebuild the image
|
||
that was just published. Pushing to main needs a token with write access —
|
||
`VERSION_BUMP_TOKEN` overrides the task token where that one cannot.
|
||
|
||
Two things any deployment has to get right, both learned the hard way:
|
||
|
||
- **Chromium needs more than the default 64Mi `/dev/shm`** or it crashes. Mount a
|
||
memory-backed volume of a few hundred Mi at `/dev/shm`.
|
||
- **The registry must be reachable without a proxy that caps request bodies.** The image
|
||
has a layer well over 100MB; a proxy with a smaller limit fails the push partway
|
||
through with `413 Payload Too Large`.
|
||
|
||
Measured around 620Mi resident with the browser up.
|