332 lines
21 KiB
Markdown
332 lines
21 KiB
Markdown
# antisocial — working notes
|
||
|
||
> `AGENTS.md` is the source of truth for agent instructions. This file is
|
||
> supplemental detail (adapter internals, per-platform quirks,
|
||
> publishing/signing). On conflict, `AGENTS.md` wins; executable sources win
|
||
> over all prose.
|
||
|
||
A self-hosted page that shows a social post without the app. StopTheMadness rewrites
|
||
links to X, Threads, Instagram, Facebook, 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. |
|
||
| Someone else's post entirely | Facebook. The linked post was not on the page, so the feed it ships alongside got read instead. Should be an error, not a post — check what `partsOfPost` matched. |
|
||
| 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.
|
||
- **`mediaFromOpenGraph` in `meta-media.ts`** is the floor shared by Instagram, Threads
|
||
and Facebook. The three are one product underneath; the first two also share the media
|
||
schema, Facebook has its own.
|
||
- **`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.
|
||
- **A video is sized by its poster**, not by the ratio the renderer puts on it. A
|
||
`<video>` with no data has a natural size of 300x150, and WebKit sizes it from that —
|
||
so one with no poster of its own gets an empty SVG of the right shape as a stand-in,
|
||
without which a portrait video sits in a squat landscape box until you press play.
|
||
- **A page with a video claims the `playback` audio session** (`public/app.js`). iOS
|
||
hands inline video the ambient session, which the Ring/Silent switch mutes, so
|
||
without this the video plays and says nothing until it goes fullscreen — and the
|
||
report that arrives is "this one platform has no sound". The session only activates
|
||
once something plays, so declaring it costs nothing on a page nobody presses play on.
|
||
- **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` — which belongs to both
|
||
forms: the HLS one used to go without, and showed an empty box where a silent post
|
||
showed a frame. 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 the token is usually a key
|
||
in that same comment's own `media_metadata`, so `resolveInlineImages` is one lookup
|
||
rather than three special cases. Usually: plenty of comments carry a Giphy token and
|
||
no `media_metadata` at all, and Giphy is the one of the three whose id means something
|
||
off Reddit, so that token alone falls back to `i.giphy.com/media/<id>/giphy.gif`. A
|
||
variant name after the id is dropped — Giphy does not serve every variant of every
|
||
gif. 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. An address
|
||
typed without a scheme counts as well, but only when it ends in an image extension —
|
||
the rule wants a host, a path *and* that extension, because comments are full of
|
||
dotted, slashed prose that must not turn into links.
|
||
- **Facebook** — the post page logged out carries everything: the caption, the author,
|
||
the files and the dimensions, in `ScheduledServerJS` payloads that are plain JSON in
|
||
ordinary `<script>` tags. What it does *not* carry is only that post. A reel ships the
|
||
next five reels of the feed under `viewer.lasso_blue_feed`, a video ships its related
|
||
videos, and each one has the same fields in the same shape as the real one — so
|
||
nothing may be read until the post has been picked out, which `partsOfPost` does by
|
||
matching the id in the address against every node that names one. An address with no
|
||
id in it (a `pfbid` permalink) is matched on `permalink_url`/`wwwURL` instead; only
|
||
then, with nothing to match on at all, does it fall back to the route's query results,
|
||
which is still narrower than the whole payload — the page ships its entire client
|
||
configuration too, thousands of nodes carrying a `name` or an `id`, and a plain search
|
||
finds a video player setting long before it finds the author. An id that matches
|
||
nothing is a *failure*: Facebook answers a link to something it no longer has by
|
||
quietly serving something else (`/watch/<id>` becomes the Watch home page, feed and
|
||
all), and reading that would put a stranger's video under the link that was shared.
|
||
One post's pieces are spread over several payload blocks — a video post keeps its
|
||
files in one, its caption in another and its author's avatar in a third — so every
|
||
claiming node is collected, not just the first. The author is whatever the payload
|
||
calls the *owner* (`actors`, `owner`, `video_owner`, `owner_as_page`); `author` on a
|
||
Facebook page means the author of a comment, which sits in the same shape with a name
|
||
and a picture, and taking the first node with a name on it finds a commenter as
|
||
readily as the poster. Media: `videoDeliveryLegacyFields.browser_native_hd_url` for a
|
||
video with `preferred_thumbnail` as its poster, `photo_image`/`image` for a picture,
|
||
and `all_subattachments.nodes` for a post of several — which is shipped empty on
|
||
single-picture posts, so only a populated one is a carousel. The CDN is signed and
|
||
serves `Range` without being asked for a referrer, but the assets are proxied like
|
||
everything else. `/share/{r,v,p,g}/<code>` links are stubs; the adapter follows one
|
||
and hands back where it landed, since the share code says nothing about what it opens.
|
||
`m.facebook.com` is a login wall logged out — always rebuild on `www.`.
|
||
- **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.
|
||
- `public/` is served as-is to the browser, so what is in there is plain JS, not TS.
|
||
`public/browsers.js` — the per-browser URL schemes behind the "open in" picker — is
|
||
a module rather than more of `app.js` so a test can import it; that is what `allowJs`
|
||
in `tsconfig.json` is for.
|
||
- 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, opened as a pull
|
||
request 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.
|
||
|
||
It arrives as a pull request rather than as a commit straight to main: pushing a branch
|
||
asks nothing of the task token beyond ordinary write access, so it does not matter what
|
||
protects main, and the changed `package.json` goes through a build before it lands.
|
||
Nothing pushes to main, so no `[skip ci]` is needed to stop the bump rebuilding the
|
||
image just published. The job is the only one that runs in a container (`node:22`, for
|
||
npm), and a job in a container is handed `sh`, not bash — hence the explicit
|
||
`shell: bash`, without which `set -o pipefail` fails the first line of the first step.
|
||
|
||
### Signing the bump commit
|
||
|
||
main requires signed commits, and a pull request carrying an unsigned one cannot be
|
||
merged — so the bump job signs the commit it makes. Not with the key the server signs
|
||
merge commits with: that one lives on the server and no runner can reach it. It uses a
|
||
dedicated release-bot SSH key instead, which also means it can be rotated on its own if
|
||
the secret ever leaks.
|
||
|
||
There is deliberately no release-bot account. A key attached to an account is only
|
||
consulted for signature checking once it has been through the web *Verify* flow, and
|
||
that flow has no API, so a bot account would need an interactive login to be worth
|
||
anything. Listing the key under `[repository.signing] TRUSTED_SSH_KEYS` instead makes
|
||
the signature verify with no account lookup at all, which is all the protected branch
|
||
asks for. `[email protected]` is therefore a label and not an identity, and
|
||
the signature is attributed to the instance's `SIGNING_NAME`/`SIGNING_EMAIL` rather
|
||
than to it. The other side of trusting a key instance-wide: a commit signed with it
|
||
verifies in *every* repository on that instance, because the trust is in the key and not
|
||
in a user whose permissions you could scope.
|
||
|
||
Set up once per instance, and again only on rotation:
|
||
|
||
1. Generate a passphrase-less key — it has to be usable unattended:
|
||
`ssh-keygen -t ed25519 -C release-bot -f release-bot -N ''`.
|
||
2. Add the public half to `TRUSTED_SSH_KEYS` in the server config and restart.
|
||
3. Store the private half as the `RELEASE_BOT_SIGNING_KEY` Actions secret — the whole
|
||
file verbatim, `-----BEGIN OPENSSH PRIVATE KEY-----` and footer included, not the
|
||
`.pub` and not a GPG export. An organisation secret covers every repository at once.
|
||
Delete both local files afterwards.
|
||
|
||
Until both are in place the bump job fails, loudly and on purpose: it checks the secret
|
||
is set and that `ssh-keygen` exists before it starts, feeds the key through
|
||
`ssh-keygen -y` so a truncated or re-wrapped one is caught as itself rather than as
|
||
"gpg failed to sign the data", and re-reads the commit for a `gpgsig` header before
|
||
pushing. Nothing else in the pipeline signs anything — release tags are made by hand,
|
||
and the merge commit is signed by the server.
|
||
|
||
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.
|