package.json has sat at 0.1.0 since the first commit, through three releases, because nothing read it. That is fine right up until something does — an image label, a health endpoint, a bug report quoting a version — at which point the tree claims to be a version that shipped long ago. A new `bump` job takes the tag that was just published, works out the next patch from it, and commits that to main. After 1.2.0 the tree says 1.2.1: not a version that exists, which is the point. A build from main is then legible as "after 1.2.0" rather than as 1.2.0 itself. It sits in publish.yml rather than a workflow of its own so that it can say `needs: build`. A version that failed to publish has not been released, and moving past it would say that it had. Prereleases are skipped for the same reason -- 1.2.3-rc1 is a candidate for a version that has not shipped, so there is nothing yet to move past. The bump goes through `npm version` rather than editing the file. The version is in the lockfile too, in two places, and a tree where those disagree is worse than one that is merely out of date. Three smaller things. The patch arithmetic forces base ten, because a patch number written 08 is otherwise read as octal and kills the job. The commit carries `[skip ci]`, or pushing it starts another build of the image that was just published. And the committer is a name that is not a person at a reserved address that can never become one, so nothing here names the instance it runs on. Pushing to main needs a token that may write to the repository. The Actions task token can where the instance allows it; where it does not, setting a VERSION_BUMP_TOKEN secret overrides it. A push that is refused fails the job with both of those as the suggestion rather than a bare 403. package.json goes to 1.2.1 here, which is where the job would have left it had it existed when 1.2.0 went out. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
224 lines
13 KiB
Markdown
224 lines
13 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`.
|
||
- **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.
|