6.0 KiB
AGENTS.md
Self-hosted page that renders a social post (X, Threads, Instagram, Facebook, TikTok, Bluesky, Reddit) without the app, by driving real headless Chromium logged-out. No auth; private-network use only. Keep deployment specifics (hosts, clusters, registries, manifests) out of this repo.
AGENTS.md (this file) is the source of truth for agent instructions. CLAUDE.md is supplemental detail (adapter internals, per-platform quirks, publishing/signing). README.md is user-facing. On conflict, this file wins; executable sources win over all prose.
Commands
npm ci
npx playwright install chromium # once
npm run dev # http://localhost:8080, runs src directly via type-stripping
npm run resolve -- '<post url>' # ground truth: real browser, prints Post JSON or error
npm run probe -- '<adapter url>' [waitMs] # what the page actually served (payload keys, DOM video/img, OG, challenge, filtered API traffic)
npm test # node:test vs test/fixtures/, no network
npm run typecheck # this IS the lint step — no ESLint
npm run build # tsc -> dist/
CI (.gitea/workflows/ci.yml) runs typecheck -> test -> build with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 (fixtures need no browser).
"This link didn't work" workflow — do not skip step 1
npm run resolve -- '<url>'— ground truth.- Classify before fixing: transient rate-limit (esp. TikTok — wait and retry) vs verification puzzle (expect redirect to
/challenge/<id>) vs structured-payload miss (carousel count wrong / poster where video belongs = DOM fall-through) vs Facebook wrong-post (feed read instead of post — must become an error) vs Reddit post-ok-no-comments (.jsonrefused, page fallback). npm run probe -- '<url the adapter loads>'— e.g. Instagram/embed/captioned/, not the post URL. Every adapter fix starts here.- Fix adapter + capture a real-payload fixture in
test/fixtures/+ assert intest/<platform>.test.ts. Fix without fixture regresses. npm test, then re-resolve end to end.
Architecture
Request → src/routes/post.ts → src/platforms/index.ts (prefix → adapter) → src/resolve.ts (cache/timeout/concurrency) → withPage (src/browser/pool.ts) → adapter → Post (src/types.ts) → src/render/post.ts.
Adding a platform: one file in src/platforms/ + one row in index.ts. Nothing downstream changes.
Rules agents get wrong
- Extraction order is load-bearing: in-flight API response → inline payload → rendered DOM → OG tags. Keep it.
PostisSegment[], not one body. Bluesky/Threads emit author chains;isAnchormarks the linked post.Segment.quoted(X, Bluesky) keeps quoted author+text+media together — never lift quoted media into the quoter's media (false attribution).Post.commentstree is Reddit-only. Renderer uses<details>per comment; nesting is what makes folding free.- Media is never linked to a CDN: register in
src/media/registry.ts, serve/m/<id>with storedReferer/Cookie, forwardRange. Exception:direct: trueHLS (no playlist rewriting). - One Chromium, one persistent context — cookies/banners accumulate deliberately. Images/video/fonts are aborted at route layer (URLs/
srcattrs survive for DOM extraction). src/platforms/scan.tspulls balanced JSON by key, including escaped-inside-JS-string (Instagram). Payload present-but-empty usually means one more encoding level.- Facebook: narrow to the linked post via
partsOfPost(id, orpermalink_url/wwwURLforpfbid; unmatched id = failure, never read the shipped-along feed). Collect all claiming nodes (caption/author/files live in different blocks); post owner comes fromactors/owner/video_owner/owner_as_page, never a bareauthor(that's a commenter). - X: decode pre-escaped text after slicing
display_text_range(UTF-16 units into escaped text); expandt.coby matching shortlink text inentities.urls, notindices; rebuild quoted URL from handle +id_str. - Reddit: prefer
.json(post + first comment page); cold profile gets a JS challenge — navigate once, retry, keep the cookie.repliesis""when empty. Video with sound (has_audio) must usehls_urldirect; silent uses proxied MP4; poster ispreview.images, neverscrubber_media_url. Gallery order comes only fromgallery_data. Comment images are tokens resolved via the comment's ownmedia_metadata, with Giphy-id →i.giphy.com/media/<id>/giphy.giffallback; render Markdown viasrc/render/markdown.ts(escape-first), neverbody_html. - TikTok: find whichever
__UNIVERSAL_DATA_FOR_REHYDRATION__scope holdsitemStruct(key varies); CDN needsReferer+ cookies; bare-pathvm./vt.codes rebuilt inbuildOriginalUrl. - Threads/Instagram share the media schema (
meta-media.ts); Threads follow-up = author replying to self (vs stranger reply with samereply_to_author); Bluesky app callsgetPostThreadV2, page fallback matches V1 only. - Never a bare error page: failed resolves render a card with platform + original URL + copy button.
- Video sizing comes from the poster (empty SVG stand-in when missing); iOS audio needs the
playbacksession inpublic/app.js.
Conventions
- TS 7, ESM,
.tsimport specifiers (tsc rewrites to.js);node --experimental-strip-typesrunssrc/test/bindirectly.strict+noUnusedLocals+noUncheckedIndexedAccessenforced bytypecheck. public/is served as-is: plain JS, not TS.public/browsers.jsstays a separate module so tests can import it (allowJs).- Tests:
node:test, fixture(payload) viatest/helpers.ts, no network. Single-file run:node --test --experimental-strip-types test/reddit.test.ts. - All post text is untrusted: emit via
htmltagged template /linkifyonly, never string-concatenated markup. Comments explain why, not what the next line does. - Config is env-only with working defaults (
src/config.ts);PROFILE_DIRpersists browser state. Chromium needs >64Mi/dev/shm.