Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc4f094fb8
|
||
|
|
c08b628cce
|
||
|
|
045a312f14
|
||
|
|
2782ba41b3
|
||
|
|
658639c0b6
|
||
|
|
bdc51df25f
|
||
|
|
0a196454d1
|
@@ -13,6 +13,7 @@ name: Publish
|
||||
# vars.IMAGE_NAME optional, defaults to this repository's owner/name
|
||||
# vars.REGISTRY_USER optional, defaults to the actor running the workflow
|
||||
# secrets.REGISTRY_TOKEN required to push
|
||||
# secrets.RELEASE_BOT_SIGNING_KEY required to sign the version bump commit
|
||||
#
|
||||
# Point REGISTRY at a host the runner reaches directly, without an intermediate
|
||||
# proxy that caps request bodies: a browser image has layers well over 100MB,
|
||||
@@ -218,6 +219,56 @@ jobs:
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "package.json ${current} -> ${NEXT}"
|
||||
|
||||
# main requires signed commits, and a pull request carrying an unsigned
|
||||
# one cannot be merged. The key the server signs merge commits with lives
|
||||
# on the server and no runner can reach it, so the bump commit is signed
|
||||
# here with a dedicated key the instance trusts through
|
||||
# `[repository.signing] TRUSTED_SSH_KEYS`. Setting that up is in
|
||||
# CLAUDE.md; nothing about it is committed here.
|
||||
- name: Configure signing as the release bot
|
||||
if: steps.bump.outputs.changed == 'true'
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.RELEASE_BOT_SIGNING_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${SIGNING_KEY}" ]; then
|
||||
echo "RELEASE_BOT_SIGNING_KEY is not set; the bump commit would be unsigned and unmergeable." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ssh-keygen >/dev/null; then
|
||||
echo "ssh-keygen is missing from this image; git cannot make SSH signatures without it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The secret holds an OpenSSH private key. git signs by shelling out
|
||||
# to ssh-keygen, which wants the key on disk beside the `.pub` it is
|
||||
# pointed at, readable only by us, and rejects it unless the trailing
|
||||
# newline survived the round trip through the secret store.
|
||||
keydir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-bot-signing"
|
||||
install -m 700 -d "${keydir}"
|
||||
printf '%s\n' "${SIGNING_KEY}" | tr -d '\r' > "${keydir}/key"
|
||||
chmod 600 "${keydir}/key"
|
||||
|
||||
# Doubles as a format check: a truncated, re-wrapped or
|
||||
# passphrase-protected key fails here rather than as "gpg failed to
|
||||
# sign the data" three steps later.
|
||||
if ! ssh-keygen -y -f "${keydir}/key" </dev/null > "${keydir}/key.pub"; then
|
||||
echo "RELEASE_BOT_SIGNING_KEY is not a usable OpenSSH private key (passphrase-protected, truncated, or re-wrapped on paste)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A name that is not a person, and an address no account backs: the
|
||||
# signature verifies against the trusted key rather than against a
|
||||
# user, so this is a label on the commit and not an identity.
|
||||
git config user.name 'Release Bot'
|
||||
git config user.email '[email protected]'
|
||||
# `gpg.format` is the historical name; `ssh` is what switches git to
|
||||
# signing with the key above rather than with a GPG key.
|
||||
git config gpg.format ssh
|
||||
git config user.signingkey "${keydir}/key.pub"
|
||||
git config commit.gpgsign true
|
||||
|
||||
# The bump 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 nothing here depends on being allowed past whatever
|
||||
@@ -236,14 +287,18 @@ jobs:
|
||||
|
||||
branch="release/bump-${NEXT}"
|
||||
|
||||
# A name that is not a person, and a reserved address that can never
|
||||
# resolve to one. Nothing here names the instance it runs on.
|
||||
git config user.name 'Release bot'
|
||||
git config user.email '[email protected]'
|
||||
|
||||
git checkout -b "${branch}"
|
||||
git add package.json package-lock.json
|
||||
git commit -m "Set the working version to ${NEXT}"
|
||||
|
||||
# An unsigned commit would go unnoticed until someone tried to merge
|
||||
# the pull request, so it fails here instead, where the cause is in
|
||||
# front of you.
|
||||
if ! grep -q "^gpgsig" <<<"$(git cat-file commit HEAD)"; then
|
||||
echo "The bump commit came out unsigned; refusing to push it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git push origin "${branch}"
|
||||
|
||||
# node rather than jq to build the request body: jq is not in this
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
1. `npm run resolve -- '<url>'` — ground truth.
|
||||
2. 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 (`.json` refused, page fallback).
|
||||
3. `npm run probe -- '<url the adapter loads>'` — e.g. Instagram `/embed/captioned/`, not the post URL. Every adapter fix starts here.
|
||||
4. Fix adapter + capture a real-payload fixture in `test/fixtures/` + assert in `test/<platform>.test.ts`. Fix without fixture regresses.
|
||||
5. `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.
|
||||
- `Post` is `Segment[]`, not one body. Bluesky/Threads emit author chains; `isAnchor` marks 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.comments` tree 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 stored `Referer`/`Cookie`, forward `Range`. Exception: `direct: true` HLS (no playlist rewriting).
|
||||
- One Chromium, one persistent context — cookies/banners accumulate deliberately. Images/video/fonts are aborted at route layer (URLs/`src` attrs survive for DOM extraction).
|
||||
- `src/platforms/scan.ts` pulls 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, or `permalink_url`/`wwwURL` for `pfbid`; unmatched id = failure, never read the shipped-along feed). Collect all claiming nodes (caption/author/files live in different blocks); post owner comes from `actors`/`owner`/`video_owner`/`owner_as_page`, never a bare `author` (that's a commenter).
|
||||
- X: decode pre-escaped text after slicing `display_text_range` (UTF-16 units into escaped text); expand `t.co` by matching shortlink text in `entities.urls`, not `indices`; 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. `replies` is `""` when empty. Video with sound (`has_audio`) must use `hls_url` direct; silent uses proxied MP4; poster is `preview.images`, never `scrubber_media_url`. Gallery order comes only from `gallery_data`. Comment images are tokens resolved via the comment's own `media_metadata`, with Giphy-id → `i.giphy.com/media/<id>/giphy.gif` fallback; render Markdown via `src/render/markdown.ts` (escape-first), never `body_html`.
|
||||
- TikTok: find whichever `__UNIVERSAL_DATA_FOR_REHYDRATION__` scope holds `itemStruct` (key varies); CDN needs `Referer` + cookies; bare-path `vm.`/`vt.` codes rebuilt in `buildOriginalUrl`.
|
||||
- Threads/Instagram share the media schema (`meta-media.ts`); Threads follow-up = author replying to self (vs stranger reply with same `reply_to_author`); Bluesky app calls `getPostThreadV2`, 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 `playback` session in `public/app.js`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- TS 7, ESM, `.ts` import specifiers (tsc rewrites to `.js`); `node --experimental-strip-types` runs `src`/`test`/`bin` directly. `strict` + `noUnusedLocals` + `noUncheckedIndexedAccess` enforced by `typecheck`.
|
||||
- `public/` is served as-is: plain JS, not TS. `public/browsers.js` stays a separate module so tests can import it (`allowJs`).
|
||||
- Tests: `node:test`, fixture(payload) via `test/helpers.ts`, no network. Single-file run: `node --test --experimental-strip-types test/reddit.test.ts`.
|
||||
- All post text is untrusted: emit via `html` tagged template / `linkify` only, never string-concatenated markup. Comments explain *why*, not what the next line does.
|
||||
- Config is env-only with working defaults (`src/config.ts`); `PROFILE_DIR` persists browser state. Chromium needs >64Mi `/dev/shm`.
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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
|
||||
@@ -279,6 +284,42 @@ image just published. The job is the only one that runs in a container (`node:22
|
||||
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
|
||||
|
||||
@@ -254,6 +254,9 @@ link preview offers.
|
||||
|
||||
## Development
|
||||
|
||||
Agent instructions live in `AGENTS.md` (source of truth); `CLAUDE.md` holds
|
||||
adapter-internals detail.
|
||||
|
||||
Needs Node 22+. The container commands below use Apple `container`; `docker` takes the
|
||||
same arguments if that is what you have.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "antisocial",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "antisocial",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.2",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@fastify/static": "10.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "antisocial",
|
||||
"version": "1.2.5",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"description": "Reads social posts back to you without the app.",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -254,15 +254,31 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
|
||||
if (scraped && !scraped.text && dom.text) scraped.text = dom.text;
|
||||
if (scraped && !scraped.handle && dom.handle) scraped.handle = dom.handle;
|
||||
|
||||
if (!scraped?.media.length) {
|
||||
// The embed no longer ships a reel's `video_url`, and its player never
|
||||
// draws a `<video>` logged out — so both payload and DOM hand back only
|
||||
// the cover frame. An image where a video belongs is a miss, not a result:
|
||||
// fall through to the post page, whose payload still carries
|
||||
// `video_versions`. Keep the embed's caption/handle/avatar either way.
|
||||
const hasVideo = scraped?.media.some((item) => item.kind === 'video') === true;
|
||||
if (!scraped?.media.length || (expectsVideo && !hasVideo)) {
|
||||
// The embed refuses some posts outright ("the link may be broken").
|
||||
// Try the post itself: its payload first, then its link preview.
|
||||
const carry = scraped;
|
||||
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
|
||||
scraped = fromPayloads(await scriptTexts(ctx)) ?? scraped;
|
||||
const fromPost = fromPayloads(await scriptTexts(ctx));
|
||||
if (fromPost?.media.length) {
|
||||
scraped = {
|
||||
...fromPost,
|
||||
...(fromPost.text ? {} : carry?.text ? { text: carry.text } : {}),
|
||||
...(fromPost.handle ? {} : carry?.handle ? { handle: carry.handle } : {}),
|
||||
...(fromPost.avatar ? {} : carry?.avatar ? { avatar: carry.avatar } : {}),
|
||||
...(fromPost.displayName ? {} : carry?.displayName ? { displayName: carry.displayName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (!scraped?.media.length) {
|
||||
const og = await fromOpenGraph(ctx);
|
||||
if (og.media.length) scraped = { ...og, ...(scraped?.handle ? { handle: scraped.handle } : {}) };
|
||||
if (og.media.length) scraped = { ...og, ...(carry?.handle ? { handle: carry.handle } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"__typename": "XIGPolarisVideoMedia",
|
||||
"pk": "3983940149641276516",
|
||||
"code": "DdJzHVRyOxk",
|
||||
"media_type": 2,
|
||||
"product_type": "clips",
|
||||
"caption": {
|
||||
"text": "I just quit my job at the BPAF and things are not looking good you guys"
|
||||
},
|
||||
"accessibility_caption": "Video by Vinny Thomas on September 11, 2026.",
|
||||
"original_height": 1280,
|
||||
"original_width": 720,
|
||||
"video_duration": 60.486,
|
||||
"image_versions2": {
|
||||
"candidates": [
|
||||
{
|
||||
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/806299489_18624728518024548_8282126995775909321_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=109&ig_cache_key=abc.3-ccb7-5&efg=cover_frame&width=720",
|
||||
"height": 1280,
|
||||
"width": 720
|
||||
},
|
||||
{
|
||||
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/806299489_18624728518024548_8282126995775909321_n.jpg?stp=dst-jpg_e15_s640x640_tt6&_nc_cat=109&ig_cache_key=abc.3-ccb7-5&efg=cover_frame&width=640",
|
||||
"height": 640,
|
||||
"width": 640
|
||||
},
|
||||
{
|
||||
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/806299489_18624728518024548_8282126995775909321_n.jpg?stp=dst-jpg_e15_s320x320_tt6&_nc_cat=109&ig_cache_key=abc.3-ccb7-5&efg=cover_frame&width=320",
|
||||
"height": 320,
|
||||
"width": 320
|
||||
}
|
||||
]
|
||||
},
|
||||
"has_audio": true,
|
||||
"video_versions": [
|
||||
{
|
||||
"type": 101,
|
||||
"url": "https://scontent-lga3-3.cdninstagram.com/o1/v/t2/f2/m86/AQOsyEB80vwVJZ1X8Ang509fDOvIQYYT5Sw8ILSanSG2u_RQcsqL4IXxAs-sjup.mp4?_nc_cat=102&_nc_sid=5e9851&efg=xpv_progressive_720&width=720"
|
||||
},
|
||||
{
|
||||
"type": 102,
|
||||
"url": "https://scontent-lga3-3.cdninstagram.com/o1/v/t2/f2/m86/AQOsyEB80vwVJZ1X8Ang509fDOvIQYYT5Sw8ILSanSG2u_RQcsqL4IXxAs-sjup.mp4?_nc_cat=102&_nc_sid=5e9851&efg=xpv_progressive_480&width=480"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { fromGraph } from '../src/platforms/instagram.ts';
|
||||
import { findMetaMedia, mediaFromMetaNode } from '../src/platforms/meta-media.ts';
|
||||
import { fixture } from './helpers.ts';
|
||||
|
||||
test('a carousel becomes one media entry per child, in order', () => {
|
||||
@@ -26,6 +27,24 @@ test('a reel becomes a progressive video with its cover frame as the poster', ()
|
||||
assert.ok(item?.width && item.height, 'expected dimensions for the aspect ratio');
|
||||
});
|
||||
|
||||
test('a reel whose embed only ships a cover frame still finds its video on the post page', () => {
|
||||
// The captioned embed stopped shipping a reel's `video_url` and never draws
|
||||
// a logged-out `<video>`, so both payload and DOM hand back the poster only.
|
||||
// The post page still carries the progressive file in `video_versions`
|
||||
// (the meta-media schema), which is where the adapter now falls through to.
|
||||
const node = findMetaMedia(fixture('instagram/reel-video-versions.json'));
|
||||
assert.ok(node, 'expected to find the media node in the post payload');
|
||||
const media = mediaFromMetaNode(node!, 'https://www.instagram.com/');
|
||||
assert.equal(media.length, 1);
|
||||
const [item] = media;
|
||||
assert.equal(item?.kind, 'video', 'a reel is a video, not its cover frame');
|
||||
assert.match(item?.url ?? '', /^https:\/\/.*\.mp4/);
|
||||
assert.ok(!item?.url.includes('.m3u8'), 'the native player needs a real file, not HLS');
|
||||
// The cover frame becomes the poster, not the media itself.
|
||||
assert.ok(item?.kind === 'video' && item.poster?.url.includes('t51.82787-15'), 'expected the cover frame as poster');
|
||||
assert.ok(item?.width === 720 && item.height === 1280, 'expected dimensions for the aspect ratio');
|
||||
});
|
||||
|
||||
test('captions keep their line breaks', () => {
|
||||
const scraped = fromGraph(fixture('instagram/carousel.json'));
|
||||
assert.ok(scraped.text?.includes('\n'), 'expected the caption to keep paragraph breaks');
|
||||
|
||||
Reference in New Issue
Block a user