Files
antisocial/CLAUDE.md
T
thatguygriffandClaude Opus 5 fb12eb6c9f
Publish / Build and push (push) Failing after 32s
CI / Typecheck, test, build (push) Successful in 34s
Slim the image, publish on version tags, drop deployment specifics
The first publish failed partway through the push with 413 Payload Too Large:
one layer was bigger than the proxy in front of the registry would accept.

Three changes, only one of which is that fix.

Keep deployment out of a public repo. The registry, image name and credentials
now come from repository variables and secrets rather than being written down
here, and the docs describe how to run the thing rather than where one
particular instance runs. PUBLIC_ORIGIN defaults to localhost. The 413 is a
proxy limit, so the fix is pointing REGISTRY at a host the runner reaches
directly; the workflow explains itself if that host is plain HTTP and the
builder's daemon has not been told to allow it.

Publish on version tags. A tag like 1.2.3 publishes :1.2.3, :1.2, :1 and
:latest; a prerelease publishes only its exact version and leaves :latest
alone. Pushes to main publish :main and :sha-<short> and no longer move
:latest, so what is deployed moves when a release says so.

Shrink the image from over 1.2GB to 353MB. The Playwright base image carries
Firefox and WebKit, which this never launches. Installing just the browser it
does launch onto a slim Node base drops two thirds of the weight, which is
worth having on a Raspberry Pi even though it does not get any single layer
under a proxy limit.

That last change surfaced something worth naming: a headless launch resolves to
Playwright's headless shell, not the full browser, so that is what every test
so far has actually been running. The image now installs exactly that binary
and pool.ts names the channel, so the two cannot drift apart.

Verified in the container: Bluesky, Instagram, X and Threads all resolve
identically on the slim image.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
2026-08-26 12:05:02 -03:00

160 lines
8.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# antisocial — working notes
A self-hosted page that shows a social post without the app. StopTheMadness rewrites
links to X, Threads, Instagram, TikTok and Bluesky 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. |
**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`.
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.
- **X** — the `platform.twitter.com` embed calls the syndication endpoint; we catch that
response. A quote post carries no media of its own, so the quoted post's media is used.
- **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.61.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`.
- **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.
## 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.
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.