From b5f9483615deda4e99e6b4817ed92b3aafa37295 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Wed, 26 Aug 2026 11:32:25 -0300 Subject: [PATCH] Initial commit: read social posts back without the app antisocial is the other half of a StopTheMadness redirect rule. Links to X, Threads, Instagram, TikTok and Bluesky get rewritten to //, and this resolves the post and shows the media and the words, with a badge saying where it came from and a button to copy the original URL. Every request drives a real headless Chromium, logged out, from a residential IP. One code path, and it survives markup changes better than parsing from the outside would. Extraction is layered, most structured first: the platform's own API response caught in flight, then an inline payload, then the rendered DOM, then Open Graph tags. Media is never linked straight at a CDN. Instagram and TikTok reject requests without a matching Referer and cookies, and proxying keeps the viewer's browser from talking to the platform at all. Range is forwarded so the native video scrubber can seek. HLS is the exception, since proxying it would mean rewriting playlists. TikTok sometimes answers with a slider puzzle. Rather than reporting that as a failure, the page is parked and the viewer is handed the puzzle: screenshots stream out, pointer events are replayed back. Solving it leaves the cookie in the shared browser context, so the retry is an ordinary request. A failed resolve is never a blank error page. The card carries the platform, the original URL and the copy button, so a broken adapter still leaves the link one tap away. Verified end to end against real shared links on all five platforms, in the container, including multi-image carousels, reels, TikTok short links and photo posts. 49 tests run the adapters against captured payloads with no network. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY --- .dockerignore | 10 + .gitea/workflows/ci.yml | 42 + .gitea/workflows/publish.yml | 83 ++ .gitignore | 6 + CLAUDE.md | 143 +++ Dockerfile | 54 + README.md | 178 ++++ bin/probe.ts | 95 ++ bin/resolve.ts | 37 + package-lock.json | 1360 +++++++++++++++++++++++++ package.json | 30 + public/app.css | 249 +++++ public/app.js | 241 +++++ public/favicon.svg | 1 + src/browser/capture.ts | 87 ++ src/browser/pool.ts | 212 ++++ src/browser/queue.ts | 61 ++ src/challenge/registry.ts | 101 ++ src/config.ts | 54 + src/media/registry.ts | 38 + src/platforms/bluesky.ts | 143 +++ src/platforms/index.ts | 42 + src/platforms/instagram.ts | 312 ++++++ src/platforms/meta-media.ts | 129 +++ src/platforms/scan.ts | 114 +++ src/platforms/threads.ts | 99 ++ src/platforms/tiktok.ts | 219 ++++ src/platforms/types.ts | 36 + src/platforms/url.ts | 28 + src/platforms/x.ts | 150 +++ src/render/challenge.ts | 40 + src/render/error.ts | 30 + src/render/html.ts | 46 + src/render/index-page.ts | 36 + src/render/layout.ts | 55 + src/render/post.ts | 100 ++ src/render/text.ts | 54 + src/resolve.ts | 45 + src/routes/challenge.ts | 119 +++ src/routes/health.ts | 24 + src/routes/media.ts | 68 ++ src/routes/post.ts | 87 ++ src/server.ts | 67 ++ src/types.ts | 95 ++ test/bluesky.test.ts | 51 + test/challenge.test.ts | 56 + test/fixtures/bluesky/images.json | 152 +++ test/fixtures/bluesky/text-only.json | 50 + test/fixtures/bluesky/video.json | 145 +++ test/fixtures/instagram/carousel.json | 290 ++++++ test/fixtures/instagram/reel.json | 171 ++++ test/fixtures/threads/image.json | 224 ++++ test/fixtures/threads/second.json | 256 +++++ test/fixtures/tiktok/video.json | 474 +++++++++ test/fixtures/x/photo.json | 286 ++++++ test/fixtures/x/text-only.json | 46 + test/helpers.ts | 10 + test/instagram.test.ts | 32 + test/meta-media.test.ts | 70 ++ test/render.test.ts | 104 ++ test/scan.test.ts | 45 + test/tiktok.test.ts | 44 + test/url.test.ts | 43 + test/x.test.ts | 69 ++ tsconfig.build.json | 11 + tsconfig.json | 32 + 66 files changed, 7881 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/publish.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bin/probe.ts create mode 100644 bin/resolve.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.css create mode 100644 public/app.js create mode 100644 public/favicon.svg create mode 100644 src/browser/capture.ts create mode 100644 src/browser/pool.ts create mode 100644 src/browser/queue.ts create mode 100644 src/challenge/registry.ts create mode 100644 src/config.ts create mode 100644 src/media/registry.ts create mode 100644 src/platforms/bluesky.ts create mode 100644 src/platforms/index.ts create mode 100644 src/platforms/instagram.ts create mode 100644 src/platforms/meta-media.ts create mode 100644 src/platforms/scan.ts create mode 100644 src/platforms/threads.ts create mode 100644 src/platforms/tiktok.ts create mode 100644 src/platforms/types.ts create mode 100644 src/platforms/url.ts create mode 100644 src/platforms/x.ts create mode 100644 src/render/challenge.ts create mode 100644 src/render/error.ts create mode 100644 src/render/html.ts create mode 100644 src/render/index-page.ts create mode 100644 src/render/layout.ts create mode 100644 src/render/post.ts create mode 100644 src/render/text.ts create mode 100644 src/resolve.ts create mode 100644 src/routes/challenge.ts create mode 100644 src/routes/health.ts create mode 100644 src/routes/media.ts create mode 100644 src/routes/post.ts create mode 100644 src/server.ts create mode 100644 src/types.ts create mode 100644 test/bluesky.test.ts create mode 100644 test/challenge.test.ts create mode 100644 test/fixtures/bluesky/images.json create mode 100644 test/fixtures/bluesky/text-only.json create mode 100644 test/fixtures/bluesky/video.json create mode 100644 test/fixtures/instagram/carousel.json create mode 100644 test/fixtures/instagram/reel.json create mode 100644 test/fixtures/threads/image.json create mode 100644 test/fixtures/threads/second.json create mode 100644 test/fixtures/tiktok/video.json create mode 100644 test/fixtures/x/photo.json create mode 100644 test/fixtures/x/text-only.json create mode 100644 test/helpers.ts create mode 100644 test/instagram.test.ts create mode 100644 test/meta-media.test.ts create mode 100644 test/render.test.ts create mode 100644 test/scan.test.ts create mode 100644 test/tiktok.test.ts create mode 100644 test/url.test.ts create mode 100644 test/x.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0c700c6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitea +node_modules +dist +test +bin +profile +*.md +.DS_Store +.env diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..6745828 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +# Typecheck, test and build on every pull request, and on main so a direct +# push cannot leave the branch red without anyone noticing. + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + check: + name: Typecheck, test, build + runs-on: ubuntu-latest + container: + image: node:22 + steps: + - uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ hashFiles('package-lock.json') }} + restore-keys: npm- + + # Nothing here drives a browser: the adapter tests run against + # captured payloads. Skipping the download keeps the job to seconds. + - name: Install + run: npm ci + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..d5c7780 --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,83 @@ +name: Publish + +# Builds the application image and pushes it to the Gitea container registry +# as git.unsupervised.ca/unsupervised/antisocial. +# +# Runs on main, and on demand. Pull requests build without pushing, so a +# broken Dockerfile is caught before it can move a published tag. + +on: + push: + branches: + - main + pull_request: + paths: + - 'Dockerfile' + - 'package.json' + - 'package-lock.json' + - '.gitea/workflows/publish.yml' + workflow_dispatch: + +env: + REGISTRY: git.unsupervised.ca + IMAGE: unsupervised/antisocial + +jobs: + build: + name: Build and push + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Images are built natively, so each carries the architecture of the + # runner that built it. Every runner in the pool is arm64, which is + # also what Kallone is. A runner of a different architecture joining + # would overwrite these tags with its own arch, at which point this + # needs buildx and a multi-arch manifest. + - name: Check Docker is available + run: | + if ! docker info >/dev/null 2>&1; then + echo "No usable Docker daemon in the job container." >&2 + echo "act_runner needs container.docker_host set, or left empty to autodetect." >&2 + exit 1 + fi + docker version --format 'client {{.Client.Version}} / server {{.Server.Version}} / arch {{.Server.Arch}}' + + - name: Work out the tags + id: tags + run: echo "sha=sha-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + # REGISTRY_TOKEN is an organisation secret: a personal access token + # with the package scope, Read and Write. Gitea's Actions task token is + # rejected by the container registry (go-gitea/gitea#23642), so this + # cannot fall back to secrets.GITHUB_TOKEN. The login user must own the + # token; set the REGISTRY_USER variable if it is not github.actor. + - name: Log in to the container registry + if: github.event_name != 'pull_request' + run: | + if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then + echo "REGISTRY_TOKEN is not set. The Actions task token cannot push packages." >&2 + exit 1 + fi + echo "${{ secrets.REGISTRY_TOKEN }}" \ + | docker login "${REGISTRY}" -u "${{ vars.REGISTRY_USER || github.actor }}" --password-stdin + + - name: Build + run: | + docker build \ + --pull \ + --tag "${REGISTRY}/${IMAGE}:${{ steps.tags.outputs.sha }}" \ + --tag "${REGISTRY}/${IMAGE}:latest" \ + --file Dockerfile \ + . + + - name: Push + if: github.event_name != 'pull_request' + run: | + docker push "${REGISTRY}/${IMAGE}:${{ steps.tags.outputs.sha }}" + docker push "${REGISTRY}/${IMAGE}:latest" + echo "Published ${REGISTRY}/${IMAGE}:${{ steps.tags.outputs.sha }}" + + - name: Log out + if: always() && github.event_name != 'pull_request' + run: docker logout "${REGISTRY}" || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f614f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store +.env +profile/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..368bbcf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,143 @@ +# 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 `//`; +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. + +Single user, no auth, tailnet only, running on Kallone (lab3). `README.md` has the +rewrite rules and the user-facing description. This file is the working context. + +## 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/`. 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 `