1 Commits
Author SHA1 Message Date
thatguygriffandClaude Opus 5 76441bb91d Reformat the README, and put the Reddit rule back together
CI / Typecheck, test, build (pull_request) Successful in 15s
Publish / Build and push (pull_request) Successful in 20s
Publish / Move the working version on (pull_request) Skipped
A Markdown formatter over the file, plus a repair to what it did on the way
through.

The Reddit rewrite rule is the longest row in the table and the only one the
formatter took apart. The pipes in its alternation are written `\|`, which is
how a literal pipe goes inside a table cell and how the X and TikTok rows have
always written theirs; the formatter dropped the backslashes, at which point
four of them became column separators. The row became seven cells, the
separator row was widened to seven to match, and `(.*)` picked up an escape
and turned into `(.\*)`.

The visible damage is a mangled table. The real damage is that the rule is
there to be copied into StopTheMadness, and what was left could not be: split
across six cells with a regex that no longer matches a Reddit URL.

The row and the separator are restored. Every rule in the table now compiles
and rewrites correctly, checked by pulling them out of the file and running
them against real URLs for each platform, including the `redd.it` short code
and a `/s/` share link.

Worth knowing that the formatter will do this again. Nothing in the repository
configures one, so this was run by hand -- but if it becomes a habit, README.md
wants excluding, because this table cannot survive it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
2026-08-27 13:23:47 -03:00
35 changed files with 130 additions and 9846 deletions
+22 -113
View File
@@ -13,7 +13,6 @@ 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,
@@ -158,26 +157,19 @@ jobs:
# it is a candidate for, so there is nothing yet to move past.
if: github.ref_type == 'tag' && !contains(github.ref_name, '-')
runs-on: ubuntu-latest
# npm does the bump, so this one job wants node. A job in a container is
# given `sh -e {0}` as its shell rather than the bash the runner's own jobs
# get, and dash has neither `pipefail` nor the `10#` below — which failed
# the first line of the first step the one time this ran. node:22 is Debian
# and carries bash, so asking for it keeps these scripts the same as the
# ones in the job above.
container:
image: node:22
defaults:
run:
shell: bash
steps:
# The tag names a commit in main's history, but the bump belongs on the
# branch, so this checks out main rather than the tag. The full history
# because a shallow clone cannot reliably push a branch back.
# branch, so this checks out main rather than the tag.
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
# The task token can push only if the instance allows Actions to
# write to the repository. Where it does not, set VERSION_BUMP_TOKEN
# to a personal access token with write access and it is used
# instead.
token: ${{ secrets.VERSION_BUMP_TOKEN || secrets.GITEA_TOKEN }}
- name: Work out the next patch version
id: next
@@ -219,113 +211,30 @@ 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
# protects main; and the pull request puts the changed package.json
# through the build before it lands. Since main is never pushed, the
# `[skip ci]` that would otherwise be needed to stop this rebuilding the
# image just published is not.
- name: Open a pull request for it
- name: Commit it to main
if: steps.bump.outputs.changed == 'true'
env:
NEXT: ${{ steps.next.outputs.next }}
RELEASED: ${{ github.ref_name }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
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}"
# `[skip ci]` because this commit is a number and nothing else:
# without it the push to main starts another build of the very image
# that was just published.
git commit -m "Set the working version to ${NEXT} [skip ci]"
# 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
if ! git push origin HEAD:main; then
echo >&2
echo "Could not push the version bump to main. Either the Actions" >&2
echo "token has no write access to this repository, or main is" >&2
echo "protected against direct pushes. Set VERSION_BUMP_TOKEN to a" >&2
echo "token that may push to main, or allow that token past the" >&2
echo "branch protection." >&2
exit 1
fi
git push origin "${branch}"
# node rather than jq to build the request body: jq is not in this
# image, and node is the one thing that certainly is.
payload="$(BRANCH="${branch}" node -e 'process.stdout.write(JSON.stringify({
head: process.env.BRANCH,
base: "main",
title: `Set the working version to ${process.env.NEXT}`,
body: `${process.env.RELEASED} has shipped, so the tree was left on a version that is published and immutable. This moves it on to ${process.env.NEXT}, which is deliberately not a version that exists.`,
}))')"
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
response="$(mktemp)"
code="$(curl -sS -o "${response}" -w '%{http_code}' \
-X POST "${api}/pulls" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "${payload}")"
case "${code}" in
201) echo "Opened ${branch} against main." ;;
# A release re-run that got this far: the branch and its pull
# request are already there, which is the state we wanted anyway.
409) echo "A pull request for ${branch} is already open." ;;
*)
echo "Could not open the pull request (HTTP ${code}):" >&2
cat "${response}" >&2
exit 1
;;
esac
-58
View File
@@ -1,58 +0,0 @@
# 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`.
+14 -122
View File
@@ -1,12 +1,7 @@
# 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
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.
@@ -40,7 +35,6 @@ different fixes:
| 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. |
| Someone else's post entirely | Facebook. The linked post was not on the page, so the feed it ships alongside got read instead. Should be an error, not a post — check what `partsOfPost` matched. |
| 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.**
@@ -95,9 +89,6 @@ Things worth knowing before editing:
- **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.
- **`mediaFromOpenGraph` in `meta-media.ts`** is the floor shared by Instagram, Threads
and Facebook. The three are one product underneath; the first two also share the media
schema, Facebook has its own.
- **`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.
@@ -105,15 +96,6 @@ Things worth knowing before editing:
`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.
- **A video is sized by its poster**, not by the ratio the renderer puts on it. A
`<video>` with no data has a natural size of 300x150, and WebKit sizes it from that —
so one with no poster of its own gets an empty SVG of the right shape as a stand-in,
without which a portrait video sits in a squat landscape box until you press play.
- **A page with a video claims the `playback` audio session** (`public/app.js`). iOS
hands inline video the ambient session, which the Ring/Silent switch mutes, so
without this the video plays and says nothing until it goes fullscreen — and the
report that arrives is "this one platform has no sound". The session only activates
once something plays, so declaring it costs nothing on a page nobody presses play on.
- **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.
@@ -162,54 +144,10 @@ Things worth knowing before editing:
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` — which belongs to both
forms: the HLS one used to go without, and showed an empty box where a silent post
showed a frame. A gallery's pictures are in `media_metadata`, keyed and unordered;
their order is only in `gallery_data`. Comment
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`. An image in a comment is
written as a token rather than an address — `![gif](giphy|Ve7wX45)`,
`![img](emote|t5_2th52|4358)`, `![img](jo8gf0ca92zd1)` — and the token is usually a key
in that same comment's own `media_metadata`, so `resolveInlineImages` is one lookup
rather than three special cases. Usually: plenty of comments carry a Giphy token and
no `media_metadata` at all, and Giphy is the one of the three whose id means something
off Reddit, so that token alone falls back to `i.giphy.com/media/<id>/giphy.gif`. A
variant name after the id is dropped — Giphy does not serve every variant of every
gif. A bare `preview.redd.it` address pasted into a comment is in there too, keyed by
the id inside the URL. Prefer `s.gif` over `s.mp4` for an animated one: a GIF moves in
an `<img>` and an MP4 needs a player. An address
typed without a scheme counts as well, but only when it ends in an image extension —
the rule wants a host, a path *and* that extension, because comments are full of
dotted, slashed prose that must not turn into links.
- **Facebook** — the post page logged out carries everything: the caption, the author,
the files and the dimensions, in `ScheduledServerJS` payloads that are plain JSON in
ordinary `<script>` tags. What it does *not* carry is only that post. A reel ships the
next five reels of the feed under `viewer.lasso_blue_feed`, a video ships its related
videos, and each one has the same fields in the same shape as the real one — so
nothing may be read until the post has been picked out, which `partsOfPost` does by
matching the id in the address against every node that names one. An address with no
id in it (a `pfbid` permalink) is matched on `permalink_url`/`wwwURL` instead; only
then, with nothing to match on at all, does it fall back to the route's query results,
which is still narrower than the whole payload — the page ships its entire client
configuration too, thousands of nodes carrying a `name` or an `id`, and a plain search
finds a video player setting long before it finds the author. An id that matches
nothing is a *failure*: Facebook answers a link to something it no longer has by
quietly serving something else (`/watch/<id>` becomes the Watch home page, feed and
all), and reading that would put a stranger's video under the link that was shared.
One post's pieces are spread over several payload blocks — a video post keeps its
files in one, its caption in another and its author's avatar in a third — so every
claiming node is collected, not just the first. The author is whatever the payload
calls the *owner* (`actors`, `owner`, `video_owner`, `owner_as_page`); `author` on a
Facebook page means the author of a comment, which sits in the same shape with a name
and a picture, and taking the first node with a name on it finds a commenter as
readily as the poster. Media: `videoDeliveryLegacyFields.browser_native_hd_url` for a
video with `preferred_thumbnail` as its poster, `photo_image`/`image` for a picture,
and `all_subattachments.nodes` for a post of several — which is shipped empty on
single-picture posts, so only a populated one is a carousel. The CDN is signed and
serves `Range` without being asked for a referrer, but the assets are proxied like
everything else. `/share/{r,v,p,g}/<code>` links are stubs; the adapter follows one
and hands back where it landed, since the share code says nothing about what it opens.
`m.facebook.com` is a login wall logged out — always rebuild on `www.`.
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
@@ -236,10 +174,6 @@ Login walls are not passed through — those need an account and cannot be solve
- **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.
- `public/` is served as-is to the browser, so what is in there is plain JS, not TS.
`public/browsers.js` — the per-browser URL schemes behind the "open in" picker — is
a module rather than more of `app.js` so a test can import it; that is what `allowJs`
in `tsconfig.json` is for.
- 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
@@ -267,58 +201,16 @@ 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, opened as a pull
request 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.
It 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 it does not matter what
protects main, and the changed `package.json` goes through a build before it lands.
Nothing pushes to main, so no `[skip ci]` is needed to stop the bump rebuilding the
image just published. The job is the only one that runs in a container (`node:22`, for
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.
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:
+16 -101
View File
@@ -2,12 +2,11 @@
Reads social posts back to you without the app.
Links to X, Threads, Instagram, Facebook, TikTok, Bluesky and Reddit get shared
constantly, and opening one means an app interstitial, a login wall, a feed you didn't
ask for, and a pile of tracking. antisocial is the other half of a StopTheMadness rewrite
rule: the link gets redirected here, and you get the post — the media and the words —
plus a badge saying where it came from and a button to copy the original URL if you do
want to go there.
Links to X, Threads, Instagram, TikTok, Bluesky and Reddit get shared constantly, and
opening one means an app interstitial, a login wall, a feed you didn't ask for, and a
pile of tracking. antisocial is the other half of a StopTheMadness rewrite rule: the link gets
redirected here, and you get the post — the media and the words — plus a badge saying
where it came from and a button to copy the original URL if you do want to go there.
Built for one person, on a private network. There is **no authentication of any kind**
anything that can reach it can drive a browser through it, so put it somewhere only you
@@ -22,47 +21,15 @@ readable in your history.
Replace `antisocial.example.com` with wherever you are running it.
Each rule is two fields. Both are on their own line below, and neither needs any
escaping — copy them straight out of this file.
```text
# X
/^https:\/\/(?:www\.|mobile\.)?(?:x|twitter)\.com\/(.*)$/
https://antisocial.example.com/x/$1
# Threads
/^https:\/\/(?:www\.)?threads\.(?:net|com)\/(.*)$/
https://antisocial.example.com/threads/$1
# Instagram
/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/
https://antisocial.example.com/ig/$1
# Facebook
/^https:\/\/(?:www\.|m\.|web\.)?facebook\.com\/(.*)$/
https://antisocial.example.com/fb/$1
# TikTok
/^https:\/\/(?:www\.|vm\.|vt\.)?tiktok\.com\/(.*)$/
https://antisocial.example.com/tiktok/$1
# Bluesky
/^https:\/\/bsky\.app\/(.*)$/
https://antisocial.example.com/bsky/$1
# Reddit
/^https:\/\/(?:www\.|old\.|new\.|np\.|m\.)?reddit\.com\/(.*)$/
https://antisocial.example.com/reddit/$1
# Reddit short links
/^https:\/\/redd\.it\/(.*)$/
https://antisocial.example.com/reddit/$1
```
A code block rather than a table, because a table cell cannot hold a bare `|` — it has
to be written `\|`, which renders correctly and copies wrongly. The alternation in these
rules is full of them, and a regex whose pipes arrive as literal pipes matches nothing
and says nothing about why.
| Platform | Find | Replace |
| --------- | ------------------------------------------------------------- | ------------------------------------------- |
| X | `/^https:\/\/(?:www\.\|mobile\.)?(?:x\|twitter)\.com\/(.*)$/` | `https://antisocial.example.com/x/$1` |
| Threads | `/^https:\/\/(?:www\.)?threads\.(?:net\|com)\/(.*)$/` | `https://antisocial.example.com/threads/$1` |
| Instagram | `/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/` | `https://antisocial.example.com/ig/$1` |
| TikTok | `/^https:\/\/(?:www\.\|vm\.\|vt\.)?tiktok\.com\/(.*)$/` | `https://antisocial.example.com/tiktok/$1` |
| Bluesky | `/^https:\/\/bsky\.app\/(.*)$/` | `https://antisocial.example.com/bsky/$1` |
| Reddit | `/^https:\/\/(?:www\.\|old\.\|new\.\|np\.\|m\.)?reddit\.com\/(.*)$/` | `https://antisocial.example.com/reddit/$1` |
| Reddit | `/^https:\/\/redd\.it\/(.*)$/` | `https://antisocial.example.com/reddit/$1` |
So `https://x.com/user/status/123` becomes
`https://antisocial.example.com/x/user/status/123`.
@@ -73,41 +40,9 @@ share codes lose their subdomain in the rewrite; a single opaque path segment is
recognised as a share code and rebuilt as `vm.tiktok.com/<code>/`, or `redd.it/<code>`
where it came from Reddit. A Reddit `/r/<sub>/s/<code>` share link is followed to the
post it points at, and that permalink — not the opaque share code — is what the copy
button hands back. A Facebook `/share/r/<code>` link is followed the same way, and
`m.facebook.com` is rebuilt on `www.` because the mobile site answers a logged-out
reader with a login page and nothing else.
button hands back.
`/` serves these rules with the live hostname already filled in, if you'd rather copy
them from there.
## Opening the original
The rules are indiscriminate, which is the point — but that catches the way back out
too. Tapping "Open on Instagram" in the browser the rules are installed in redirects
straight back here, so the one button meant to get you to the app is the one button that
cannot.
The way past it is to hand the address to a *different* browser, by its own URL scheme.
`/` has a picker for which one:
| Browser | macOS | iOS |
| --- | --- | --- |
| Chrome | `googlechromes://…` | `googlechromes://…` |
| Edge | `microsoft-edge:https://…` | `microsoft-edge-https://…` |
| Orion | `orion://open-url?url=…` | `orion://open-url?url=…` |
| Firefox | — | `firefox://open-url?url=…` |
Firefox on macOS registers no scheme, so it is not offered there; nor is Safari itself,
which is the browser being escaped. The choice is kept in that browser's own
`localStorage` — not a cookie and not synced, because "which browser is installed" is a
fact about the device, not about you, and the phone's answer is not the Mac's.
Until a browser is chosen there is no "Open on …" button at all — the plain address is
the one thing it must not offer, since following it only comes back here. The markup
carries that address anyway, hidden, and the script swaps in the scheme and reveals the
button once there is somewhere to send it. So with JavaScript off, or with the default
left alone, the copy button and the selectable URL are the whole of it, which is all
that was ever load-bearing.
`/` serves this table with the live hostnames, if you'd rather read it there.
## How it works
@@ -129,7 +64,6 @@ Each adapter layers its extraction, most structured first:
| Bluesky | the public AT Protocol API | `getPostThread`; falls back to the post page |
| X | `platform.twitter.com` embed | the `cdn.syndication.twimg.com/tweet-result` response |
| Instagram | `/embed/captioned/` | `shortcode_media`, then the rendered `<video>`/`<img>` |
| Facebook | the post page | the Relay payloads, narrowed to the nodes naming that post |
| TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
| Threads | the post page | the Relay payloads in `<script type="application/json">` |
| Reddit | the post's own `.json` | the post and the first page of comments; falls back to the page |
@@ -163,13 +97,6 @@ which is why it only appears once the script has run. What was behind a _load mo
not fetched — that is a second page and often a third — but it is counted and said out
loud rather than quietly dropped.
Pictures inside comments are shown as pictures. Reddit writes them as a token rather
than an address — a Giphy id, a subreddit emote, or an image uploaded to the comment —
and all three are looked up in the comment's own metadata to find the real file. An
image address someone simply pasted is shown too, which on Reddit is how most of them
arrive. All of it goes through the same `/m/` proxy as everything else, so reading a
comment thread never has your browser talking to Reddit.
Media never gets linked straight at a CDN. Instagram and TikTok reject requests without
a matching `Referer` (and sometimes cookies), and proxying keeps your browser from
talking to the platform at all. Every asset is registered under an opaque `/m/<id>` and
@@ -179,10 +106,6 @@ The one exception is Bluesky video, which is an HLS playlist — proxying it wou
rewriting the manifest and every segment, so it is linked directly. Safari plays HLS
natively; other browsers show a note.
A page carrying a video asks iOS for the playback audio session. Without it a video
playing inline is treated as ambience and the Ring/Silent switch mutes it, so the post
plays perfectly and says nothing unless you go fullscreen.
Resolved posts are cached in memory for an hour, so a reload or a back button doesn't
drive the browser again.
@@ -214,11 +137,6 @@ Known rough edges:
- **Instagram is the least reliable.** It only ships the structured payload some of the
time. When it doesn't, the rendered DOM carries single images and reels fine, but a
carousel will come back as its first image only.
- **Facebook ships the post surrounded by other people's posts.** A reel arrives with
the next five reels of the feed attached, in exactly the same shape. Only the nodes
naming the post in the address are read, so a link to something Facebook no longer has
— which it answers by quietly serving the Watch home page — fails rather than showing
a stranger's video under your link.
- **TikTok sometimes answers with a slider puzzle** instead of the post. You get handed
the puzzle rather than an error — see below.
- **Reddit refuses `.json` to a browser it has never seen.** The first request of a cold
@@ -254,9 +172,6 @@ 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "antisocial",
"version": "1.3.2",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "antisocial",
"version": "1.3.2",
"version": "1.2.1",
"license": "UNLICENSED",
"dependencies": {
"@fastify/static": "10.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "antisocial",
"version": "1.3.2",
"version": "1.2.1",
"private": true,
"description": "Reads social posts back to you without the app.",
"license": "UNLICENSED",
-43
View File
@@ -242,30 +242,6 @@ main { max-width: 680px; margin: 0 auto; }
.index__table td { padding: 8px 8px 8px 0; border-top: 1px solid var(--line); vertical-align: middle; }
.index__table code { overflow-wrap: anywhere; }
.picker {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
/* The control ships hidden and is revealed by the script, and `display:
flex` above would otherwise beat the browser's own rule for [hidden]. */
.picker[hidden] { display: none; }
.picker__label { font-size: 13px; color: var(--ink-dim); }
.picker__select {
font: inherit;
font-size: 14px;
padding: 7px 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--card);
color: var(--ink);
}
.picker__note { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--ink-dim); }
/* ---------- verification puzzle ---------- */
.challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); }
@@ -580,22 +556,3 @@ main { max-width: 680px; margin: 0 auto; }
max-height: 45dvh;
min-height: 0;
}
/* A picture someone put in a comment. Capped hard: it is a remark inside a
conversation, not the thing the page is about. */
.c__img {
display: block;
max-width: min(100%, 420px);
max-height: 40vh;
max-height: 40dvh;
width: auto;
height: auto;
margin: 8px 0;
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--ink) 4%, transparent);
}
/* A lone image is the whole comment more often than not, so it should not
carry a paragraph's worth of space above it as well as its own. */
.c__body > p:first-child > .c__img:first-child { margin-top: 2px; }
-75
View File
@@ -1,10 +1,7 @@
// Progressive enhancement only. Without this file the page still shows the
// media in a swipeable rail and the original URL as selectable text.
import { browsersFor, detectOs, openUrlFor } from './browsers.js';
const VIEW_KEY = 'antisocial:view';
const BROWSER_KEY = 'antisocial:browser';
function setupCopy() {
for (const button of document.querySelectorAll('.copy')) {
@@ -40,62 +37,6 @@ function setupCopy() {
}
}
function storedBrowser() {
try {
return localStorage.getItem(BROWSER_KEY) ?? 'default';
} catch {
return 'default';
}
}
// The markup carries the plain address, which is the one thing the button
// must not offer: followed in the browser the rewrite rules are installed
// in, it redirects straight back to this page. So the link ships hidden and
// is only revealed once a browser has been picked to hand it to. With no
// choice made -- or none this system has a scheme for -- the copy button
// and the URL below it are the whole of it.
function applyBrowser(id) {
const os = detectOs();
for (const link of document.querySelectorAll('.original__open')) {
link.dataset.original ??= link.getAttribute('href');
const href = openUrlFor(link.dataset.original, id, os);
link.href = href;
link.hidden = href === link.dataset.original;
}
}
// Only on the index page, and only with this file running: the choice is
// useless without the rewriting above, so the control ships hidden.
function setupBrowserPicker() {
const select = document.querySelector('.picker__select');
if (!select) return;
for (const browser of browsersFor(detectOs())) {
const option = document.createElement('option');
option.value = browser.id;
option.textContent = browser.label;
select.append(option);
}
// A stored id with no scheme on this system would leave the control
// showing nothing at all, so fall back rather than render a blank.
const stored = storedBrowser();
select.value = [...select.options].some((option) => option.value === stored) ? stored : 'default';
select.addEventListener('change', () => {
try {
localStorage.setItem(BROWSER_KEY, select.value);
} catch {
// Private browsing. The choice just won't survive the page.
}
applyBrowser(select.value);
});
select.closest('.picker').hidden = false;
}
// One per rail: a post can carry several -- a thread of them, or a post and
// the post it quotes -- and wiring only the first leaves the rest inert.
function setupMedia(media) {
@@ -155,19 +96,6 @@ function setupMedia(media) {
}
}
// On iOS a video playing inline gets the "ambient" audio session, which the
// Ring/Silent switch mutes -- so the post plays perfectly and says nothing,
// and the only way to hear it is to go fullscreen. Claiming "playback" says
// what is true here: the sound is the point, not decoration. The session is
// not activated until something actually plays, so declaring it up front
// interrupts nothing; it is declared only on a page that has a video so an
// ordinary text post never claims it at all.
function setupAudioSession() {
if (!('audioSession' in navigator)) return;
if (!document.querySelector('video')) return;
navigator.audioSession.type = 'playback';
}
// Every comment is a <details open>, so folding one already works with this
// file missing. All this adds is doing the whole page at once, which is why
// the button ships hidden and is only revealed here.
@@ -199,10 +127,7 @@ function setupComments() {
}
setupCopy();
applyBrowser(storedBrowser());
setupBrowserPicker();
for (const media of document.querySelectorAll('.media')) setupMedia(media);
setupAudioSession();
setupComments();
// --- Verification puzzles -------------------------------------------------
-64
View File
@@ -1,64 +0,0 @@
// The rewrite rules that send a shared link here catch the original link on
// the way back out too, so in Safari "Open on <platform>" lands back on this
// page. Handing the address to a different browser is the way out, and the
// only way to do that from a web page is that browser's own URL scheme --
// which every one of them spells differently. Some swap the scheme, some
// prefix it, some take the whole address as a query parameter.
/** http -> googlechrome://, https -> googlechromes:// */
const chrome = (url) => url.replace(/^http(s?):/, 'googlechrome$1:');
// Edge keeps the original scheme either way, but on macOS it stays in the
// address and the scheme is prefixed, while on iOS it is folded into the
// scheme itself.
const edgeMacos = (url) => `microsoft-edge:${url}`;
const edgeIos = (url) => url.replace(/^http(s?):/, 'microsoft-edge-http$1:');
/** Firefox and Orion take the address as a parameter instead. */
const openUrl = (scheme) => (url) => `${scheme}://open-url?url=${encodeURIComponent(url)}`;
// `null` means the browser is there but ships no scheme to reach it on that
// system, so there is nothing to offer beyond the plain link.
export const BROWSERS = [
{ id: 'default', label: 'Default browser', macos: null, ios: null },
{ id: 'chrome', label: 'Chrome', macos: chrome, ios: chrome },
{ id: 'edge', label: 'Edge', macos: edgeMacos, ios: edgeIos },
{ id: 'firefox', label: 'Firefox', macos: null, ios: openUrl('firefox') },
{ id: 'orion', label: 'Orion', macos: openUrl('orion'), ios: openUrl('orion') },
];
/**
* iPadOS calls itself MacIntel, so the platform string alone cannot tell the
* two apart; the touch points can. Everything else gets the desktop table --
* the only desktop this is ever opened on is a Mac.
*
* @param {{ platform?: string, maxTouchPoints?: number }} [nav]
*/
export function detectOs(nav = globalThis.navigator) {
const platform = nav?.platform ?? '';
if (/^iP(hone|ad|od)/.test(platform)) return 'ios';
if (platform === 'MacIntel' && (nav?.maxTouchPoints ?? 0) > 1) return 'ios';
return 'macos';
}
/** The browsers worth offering here: the default, plus the ones this system
* actually has a scheme for. */
export function browsersFor(os) {
return BROWSERS.filter((browser) => browser.id === 'default' || browser[os]);
}
function browserById(id) {
return BROWSERS.find((browser) => browser.id === id);
}
/**
* The address to open. A browser with no scheme on this system, an unknown
* id, or anything that is not an ordinary web link is handed back untouched:
* a dead custom scheme opens nothing at all, which is worse than the plain
* link opening in the wrong browser.
*/
export function openUrlFor(url, browserId, os) {
const rewrite = browserById(browserId)?.[os];
if (!rewrite || !/^https?:\/\//i.test(url)) return url;
return rewrite(url);
}
-451
View File
@@ -1,451 +0,0 @@
import { ResolveError, oneSegment, type Author, type Media, type Post } from '../types.ts';
import { readOpenGraph } from '../browser/capture.ts';
import { mediaFromOpenGraph, metaHeaders } from './meta-media.ts';
import { hostMatcher, stripTracking } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts';
const REFERER = 'https://www.facebook.com/';
type FbImage = { uri?: string; width?: number; height?: number };
/**
* One picture or one video, in the several shapes Facebook writes them in:
* a photo permalink makes the photo the node itself, a story hangs it off an
* attachment, and a video keeps its files in a sub-object of their own.
*/
type FbMediaNode = {
image?: FbImage;
photo_image?: FbImage;
viewer_image?: FbImage;
accessibility_caption?: string;
width?: number;
height?: number;
length_in_second?: number;
preferred_thumbnail?: { image?: FbImage };
thumbnailImage?: FbImage;
videoDeliveryLegacyFields?: {
browser_native_hd_url?: string;
browser_native_sd_url?: string;
};
};
type FbAttachment = {
media?: FbMediaNode;
styles?: { attachment?: { media?: FbMediaNode } };
all_subattachments?: { nodes?: FbAttachment[] };
};
type FbActor = {
id?: string;
name?: string;
url?: string;
profile_picture?: FbImage;
displayPicture?: FbImage;
};
export type Scraped = {
media: Media[];
text?: string;
author?: FbActor;
postedAt?: string;
};
/**
* Every node in `roots`, breadth-first.
*
* Order is the whole point: within one post the node nearest the top of the
* payload is the post itself, and the ones below it are the neighbouring
* album photo, the comment, the recommendation.
*/
function* nodes(roots: readonly unknown[]): Generator<Record<string, unknown>> {
const seen = new Set<unknown>();
const queue: unknown[] = [...roots];
for (let i = 0; i < queue.length; i += 1) {
const value = queue[i];
if (value === null || typeof value !== 'object' || seen.has(value)) continue;
seen.add(value);
if (Array.isArray(value)) {
queue.push(...value);
continue;
}
yield value as Record<string, unknown>;
queue.push(...Object.values(value));
}
}
/** Two Facebook addresses pointing at the same thing. */
function samePath(a: string, b: string): boolean {
try {
const path = (raw: string): string =>
new URL(raw).pathname.replace(/\/+$/, '').toLowerCase();
return path(a) === path(b) && path(a) !== '';
} catch {
return false;
}
}
/** Whether this node says, one way or another, that it is the linked post. */
function claimsPost(node: Record<string, unknown>, id: string | undefined, url: string): boolean {
if (id !== undefined) {
for (const key of ['id', 'post_id', 'video_id', 'legacy_story_hideable_id']) {
if (node[key] === id) return true;
}
}
for (const key of ['permalink_url', 'wwwURL', 'shareable_url', 'url']) {
const value = node[key];
if (typeof value === 'string' && samePath(value, url)) return true;
}
return false;
}
/**
* The route's own query results.
*
* Used when nothing in the payload names the post. Not the whole payload:
* alongside the post a Facebook page ships its entire client configuration,
* which is thousands of nodes deep and full of objects carrying a `name` or
* an `id`, and searching that finds a video player setting before it finds
* the author.
*/
function queryResults(payloads: readonly unknown[]): unknown[] {
const found: unknown[] = [];
for (const node of nodes(payloads)) {
const result = node['result'];
if (result !== null && typeof result === 'object' && 'data' in result) {
found.push((result as { data: unknown }).data);
}
}
return found;
}
/**
* The parts of the payload that belong to the post that was linked.
*
* A Facebook page is never only the post it was asked for: a reel arrives
* with the next five reels of the feed attached, a video with its related
* videos, and every one of them carries the same fields in the same shape as
* the real one. What separates them is the id, which is in the address and
* repeated on every node of the post it belongs to.
*
* One post's pieces are spread over several of these blocks a video post
* keeps its files in one and its caption in another so this collects every
* node that claims the post rather than stopping at the first.
*
* Empty means the page is not the post. Where the address named an id and
* nothing on the page answers to it, Facebook has quietly served something
* else: a `/watch/<id>` whose video is gone comes back as the Watch home
* page, feed and all, and reading that would put a stranger's video under
* the link that was shared. Only an address with no id in it a `pfbid`
* permalink falls back to the query results.
*/
export function partsOfPost(
payloads: readonly unknown[],
id: string | undefined,
url: string,
): unknown[] {
const owned: unknown[] = [];
for (const node of nodes(payloads)) {
if (claimsPost(node, id, url)) owned.push(node);
}
if (owned.length > 0) return owned;
return id === undefined ? queryResults(payloads) : [];
}
export function mediaFromNode(node: FbMediaNode): Media[] {
const fetchHeaders = metaHeaders(REFERER);
const legacy = node.videoDeliveryLegacyFields;
const video = legacy?.browser_native_hd_url ?? legacy?.browser_native_sd_url;
if (video) {
const poster = node.preferred_thumbnail?.image?.uri ?? node.thumbnailImage?.uri;
return [{
kind: 'video',
url: video,
fetchHeaders,
...(poster ? { poster: { url: poster, fetchHeaders } } : {}),
...(node.length_in_second ? { durationSec: Math.round(node.length_in_second) } : {}),
...(node.width ? { width: node.width } : {}),
...(node.height ? { height: node.height } : {}),
}];
}
const image = node.photo_image ?? node.image ?? node.viewer_image;
if (!image?.uri) return [];
return [{
kind: 'image',
url: image.uri,
fetchHeaders,
...(node.accessibility_caption ? { alt: node.accessibility_caption } : {}),
...(image.width ? { width: image.width } : {}),
...(image.height ? { height: image.height } : {}),
}];
}
/**
* Everything hanging off a story.
*
* `all_subattachments` is what makes a post of several pictures several
* pictures but it is shipped on single-picture posts too, as an empty
* list, so only a populated one is a carousel.
*/
export function mediaFromAttachments(attachments: readonly FbAttachment[]): Media[] {
return attachments.flatMap((attachment) => {
const children = attachment.all_subattachments?.nodes;
if (children?.length) return mediaFromAttachments(children);
const node = attachment.styles?.attachment?.media ?? attachment.media;
return node ? mediaFromNode(node) : [];
});
}
function findMedia(owned: readonly unknown[]): Media[] {
// Attachments first: they are the only shape that can hold more than one
// picture, and a post with several would otherwise come back as its first.
for (const node of nodes(owned)) {
const attachments = node['attachments'];
if (!Array.isArray(attachments) || attachments.length === 0) continue;
const media = mediaFromAttachments(attachments as FbAttachment[]);
if (media.length > 0) return media;
}
// A photo permalink has no story around it; the node is the picture.
for (const node of nodes(owned)) {
const media = mediaFromNode(node as FbMediaNode);
if (media.length > 0) return media;
}
return [];
}
function findText(owned: readonly unknown[]): string | undefined {
for (const node of nodes(owned)) {
const message = node['message'];
if (message === null || typeof message !== 'object') continue;
const text = (message as Record<string, unknown>)['text'];
if (typeof text === 'string' && text.length > 0) return text;
}
return undefined;
}
function findPostedAt(owned: readonly unknown[]): string | undefined {
for (const node of nodes(owned)) {
for (const key of ['creation_time', 'created_time']) {
const seconds = node[key];
if (typeof seconds === 'number' && seconds > 0) {
return new Date(seconds * 1000).toISOString();
}
}
}
return undefined;
}
function isActor(value: unknown): value is FbActor {
if (value === null || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
return typeof record['name'] === 'string' && record['name'].length > 0;
}
/**
* Who posted it.
*
* Facebook calls the author of a post its owner, and keeps `author` for the
* author of a *comment* which sits on the same page, in the same shape,
* with a name and a picture just like the poster's. Taking the first node
* carrying a name finds a commenter as readily as the person being read, so
* only the keys that mean "owner" count.
*
* The name, the vanity address and the picture are rarely all on one node,
* so the gaps are filled from the other nodes describing the same person
* matched on id, never on position, so a commenter's picture can never end
* up under the poster's name.
*/
export function findAuthor(owned: readonly unknown[]): FbActor | undefined {
const owners: FbActor[] = [];
for (const node of nodes(owned)) {
for (const key of ['actors', 'owner', 'video_owner', 'owner_as_page']) {
const value = node[key];
const actor = Array.isArray(value) ? value[0] : value;
if (isActor(actor)) owners.push(actor);
}
}
const first = owners[0];
if (!first) return undefined;
// An owner with no id of its own is only itself: filling its gaps from
// every other id-less node would be matching on nothing at all.
const same = first.id === undefined ? [first] : owners.filter((actor) => actor.id === first.id);
const picture = same.find((actor) => actor.profile_picture?.uri ?? actor.displayPicture?.uri);
return {
...first,
...(first.url ? {} : { url: same.find((actor) => actor.url)?.url }),
...(picture ? { profile_picture: picture.profile_picture ?? picture.displayPicture } : {}),
};
}
/** Everything the payload has to say about the post that was linked. */
export function scrapeParts(owned: readonly unknown[]): Scraped {
const author = findAuthor(owned);
const text = findText(owned);
const postedAt = findPostedAt(owned);
return {
media: findMedia(owned),
...(text ? { text } : {}),
...(author ? { author } : {}),
...(postedAt ? { postedAt } : {}),
};
}
/**
* The numeric id Facebook keys the post by, wherever this address keeps it.
*
* `/photo/?fbid=`, `/watch/?v=` and `permalink.php?story_fbid=` put it in the
* query; everything else ends on it. `id` is deliberately not read: on
* `permalink.php` it is the page's, not the post's.
*/
export function postIdFrom(url: URL): string | undefined {
for (const key of ['fbid', 'v', 'story_fbid']) {
const value = url.searchParams.get(key);
if (value && /^\d{6,}$/.test(value)) return value;
}
const segments = url.pathname.split('/').filter(Boolean);
return segments.reverse().find((segment) => /^\d{6,}$/.test(segment));
}
/** A profile address is one segment and nothing else. */
function vanityFrom(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
const match = /^\/([A-Za-z0-9.]+)\/?$/.exec(new URL(url).pathname);
const name = match?.[1];
return name && !name.endsWith('.php') ? name : undefined;
} catch {
return undefined;
}
}
/** Shared page links lead with the page: `/nasa/posts/…`, `/nasa/videos/…`. */
function vanityFromPath(pathRest: string): string | undefined {
return /^([A-Za-z0-9.]+)\/(?:posts|videos|photos)\//.exec(pathRest)?.[1];
}
function authorFrom(actor: FbActor | undefined, pathRest: string): Author {
const vanity = vanityFrom(actor?.url) ?? vanityFromPath(pathRest);
const avatar = actor?.profile_picture?.uri ?? actor?.displayPicture?.uri;
// Facebook has no universal @handle: a page has a vanity address, and a
// person may have nothing but a numeric id. Where there is no vanity the
// name is the only thing to call them by — an invented handle would be a
// name nobody could look up.
return {
handle: vanity ? `@${vanity}` : (actor?.name ?? 'Facebook'),
...(actor?.name && vanity ? { displayName: actor.name } : {}),
...(avatar ? { avatar: { url: avatar, fetchHeaders: metaHeaders(REFERER) } } : {}),
};
}
/** The payloads the page ships, which are plain JSON in ordinary `<script>`
* tags rather than the `type="application/json"` Threads uses. */
async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> {
const texts = await ctx.page
.evaluate(() =>
[...document.querySelectorAll('script')]
.map((el) => el.textContent ?? '')
.filter((text) => text.startsWith('{') && text.length > 256),
)
.catch(() => [] as string[]);
return texts.flatMap((text) => {
try {
return [JSON.parse(text) as unknown];
} catch {
return [];
}
});
}
/** Absolute floor: whatever the page offers a link preview. */
async function fromOpenGraph(ctx: ResolveContext): Promise<Scraped> {
const og = await readOpenGraph(ctx.page);
return {
media: mediaFromOpenGraph(og, REFERER),
...(og['og:description'] ? { text: og['og:description'] } : {}),
};
}
async function resolve(ctx: ResolveContext): Promise<Post> {
const { page, pathRest } = ctx;
let originalUrl = ctx.originalUrl;
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
// `/share/…` links are opaque stubs, and what they point at is only on the
// other side of the redirect. Hand back where it landed rather than the
// share code, which says nothing about what it opens.
if (/^share\//.test(pathRest)) {
try {
originalUrl = stripTracking(new URL(page.url())).href;
} catch {
// Whatever it landed on is not a URL we can take apart; keep the one
// we were given.
}
}
const owned = partsOfPost(
await inlinePayloads(ctx),
postIdFrom(new URL(originalUrl)),
originalUrl,
);
if (owned.length === 0) {
throw new ResolveError(
'Facebook served something else for that link — the post may be gone',
'facebook',
originalUrl,
);
}
const scraped = scrapeParts(owned);
if (scraped.media.length === 0 && !scraped.text) {
const og = await fromOpenGraph(ctx);
if (og.media.length > 0 || og.text) {
return toPost({ ...og, ...(scraped.author ? { author: scraped.author } : {}) }, originalUrl, pathRest);
}
throw new ResolveError(
'Facebook would not show that post logged out',
'facebook',
originalUrl,
);
}
return toPost(scraped, originalUrl, pathRest);
}
function toPost(scraped: Scraped, originalUrl: string, pathRest: string): Post {
return {
platform: 'facebook',
platformLabel: 'Facebook',
originalUrl,
author: authorFrom(scraped.author, pathRest),
textPosition: 'above',
segments: oneSegment({
...(scraped.text ? { text: scraped.text } : {}),
media: scraped.media,
...(scraped.postedAt ? { postedAt: scraped.postedAt } : {}),
}),
};
}
export const facebook: PlatformSpec = {
id: 'facebook',
label: 'Facebook',
prefix: 'fb',
canonicalHost: 'www.facebook.com',
textPosition: 'above',
// `m.` and `web.` are the same site behind a login wall, so a link to one
// is rebuilt on `www.` rather than followed where it points.
matchesHost: hostMatcher('facebook.com', 'fb.com', 'fb.watch'),
resolve,
};
+1 -4
View File
@@ -6,11 +6,8 @@ import { instagram } from './instagram.ts';
import { tiktok } from './tiktok.ts';
import { bluesky } from './bluesky.ts';
import { reddit } from './reddit.ts';
import { facebook } from './facebook.ts';
export const platforms: readonly PlatformSpec[] = [
x, threads, instagram, facebook, tiktok, bluesky, reddit,
];
export const platforms: readonly PlatformSpec[] = [x, threads, instagram, tiktok, bluesky, reddit];
const byPrefix = new Map(platforms.map((p) => [p.prefix, p]));
+19 -27
View File
@@ -1,12 +1,6 @@
import { ResolveError, oneSegment, type Media, type Post } from '../types.ts';
import { readOpenGraph } from '../browser/capture.ts';
import {
findCaption,
findMetaMedia,
mediaFromMetaNode,
mediaFromOpenGraph,
metaHeaders,
} from './meta-media.ts';
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts';
import { extractJsonObjectAfterKey } from './scan.ts';
import { hostMatcher } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts';
@@ -208,8 +202,22 @@ function fromPayloads(texts: string[]): Scraped | undefined {
/** Absolute floor: whatever the page offers a link preview. */
async function fromOpenGraph(ctx: ResolveContext): Promise<Scraped> {
const og = await readOpenGraph(ctx.page);
const fetchHeaders = metaHeaders(REFERER);
const media: Media[] = [];
if (og['og:video']) {
media.push({
kind: 'video',
url: og['og:video'],
fetchHeaders,
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}),
});
} else if (og['og:image']) {
media.push({ kind: 'image', url: og['og:image'], fetchHeaders });
}
return {
media: mediaFromOpenGraph(og, REFERER),
media,
...(og['og:description'] ? { text: og['og:description'] } : {}),
};
}
@@ -254,31 +262,15 @@ 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;
// 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)) {
if (!scraped?.media.length) {
// 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);
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 } : {}),
};
}
scraped = fromPayloads(await scriptTexts(ctx)) ?? scraped;
if (!scraped?.media.length) {
const og = await fromOpenGraph(ctx);
if (og.media.length) scraped = { ...og, ...(carry?.handle ? { handle: carry.handle } : {}) };
if (og.media.length) scraped = { ...og, ...(scraped?.handle ? { handle: scraped.handle } : {}) };
}
}
+2 -25
View File
@@ -1,10 +1,8 @@
import type { Media } from '../types.ts';
/**
* Instagram, Threads and Facebook are the same product underneath. The first
* two serve the same media schema, so both normalize through here; Facebook's
* is its own, but the headers its CDN wants and the link preview it falls
* back to are shared with them.
* Instagram and Threads are the same product underneath and serve the same
* media schema, so both adapters normalize through here.
*/
type Candidate = { url?: string; width?: number; height?: number };
@@ -68,27 +66,6 @@ export function mediaFromMetaNode(node: MetaMediaNode, referer: string): Media[]
return [];
}
/**
* The link preview as media: the floor on all three, and all that is left
* when a post is refused to a logged-out reader.
*/
export function mediaFromOpenGraph(og: Record<string, string>, referer: string): Media[] {
const fetchHeaders = metaHeaders(referer);
if (og['og:video']) {
return [{
kind: 'video',
url: og['og:video'],
fetchHeaders,
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}),
}];
}
if (og['og:image']) {
return [{ kind: 'image', url: og['og:image'], fetchHeaders }];
}
return [];
}
function nonEmptyArray(value: unknown): boolean {
return Array.isArray(value) && value.length > 0;
}
+7 -56
View File
@@ -62,7 +62,6 @@ type Link = {
type CommentData = {
author?: string;
body?: string;
media_metadata?: Record<string, MediaMeta>;
created_utc?: number;
score?: number;
score_hidden?: boolean;
@@ -104,10 +103,6 @@ function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[
const common = {
...sized(video.width, video.height),
...(video.duration ? { durationSec: video.duration } : {}),
// The still belongs to the video and not to the format it is served in.
// The HLS branch used to drop it, which is why a post with sound showed
// an empty box where every silent one showed a frame.
...(poster ? { poster: { url: poster } } : {}),
};
if (video.has_audio !== false && video.hls_url) {
@@ -118,7 +113,12 @@ function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[
if (url === video.hls_url) {
return [{ kind: 'video', url, hls: true, direct: true, ...common }];
}
return [{ kind: 'video', url, ...common }];
return [{
kind: 'video',
url,
...(poster ? { poster: { url: poster } } : {}),
...common,
}];
}
/** One entry of a gallery post. Reddit keeps the pictures somewhere other
@@ -188,55 +188,6 @@ function bodyOf(link: Link): string | undefined {
return undefined;
}
/** The whole of `![...](...)`, with the target captured. */
const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g;
/**
* A Giphy token, which unlike the others says what it points at.
*
* Reddit writes a variant name after the id on some of them
* (`giphy|abc123|downsized`); only the id is kept, because Giphy does not
* serve every variant for every gif but always serves the full one.
*/
const GIPHY_TOKEN = /^giphy\|([A-Za-z0-9]+)(?:\|[a-z_]+)?$/;
/**
* Point a comment's inline images at something fetchable.
*
* Reddit writes them as `![gif](giphy|Ve7wX45)`, `![img](emote|t5_2th52|4358)`
* or `![img](jo8gf0ca92zd1)` a token rather than an address. Usually the
* token is a key in that same comment's `media_metadata`, which is where the
* real URL is, so one lookup covers all three and none of them needs naming
* here.
*
* Usually, not always: Reddit ships plenty of comments carrying a Giphy token
* and no `media_metadata` at all, and with nothing to look the token up in
* those showed the token itself where the gif should have been. Giphy is the
* one kind that can be resolved without the lookup, the id in it being Giphy's
* own, so it falls back to Giphy's address for that id. The other two cannot:
* their ids mean nothing off Reddit.
*
* A target that is already an address is not a key, so it falls through
* untouched.
*/
export function resolveInlineImages(
body: string,
meta: Record<string, MediaMeta> | undefined,
): string {
return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => {
const entry = meta?.[token];
if (entry?.status === 'valid') {
// An animated one has both; the GIF plays in an `<img>` on its own,
// which an MP4 does not.
const url = entry.s?.gif ?? entry.s?.u;
if (url) return `![${alt}](${url})`;
}
const giphy = GIPHY_TOKEN.exec(token)?.[1];
return giphy ? `![${alt}](https://i.giphy.com/media/${giphy}/giphy.gif)` : whole;
});
}
export function commentsFrom(listing: Listing<CommentData> | undefined): {
comments: Comment[];
more: number;
@@ -258,7 +209,7 @@ export function commentsFrom(listing: Listing<CommentData> | undefined): {
comments.push({
author: authorName(data.author),
...(data.body ? { text: resolveInlineImages(data.body, data.media_metadata) } : {}),
...(data.body ? { text: data.body } : {}),
...(isoFrom(data.created_utc) ? { postedAt: isoFrom(data.created_utc) } : {}),
// Reddit hides the score on a new comment so an early downvote cannot
// steer the rest. Showing a placeholder 1 would be a lie.
+15 -9
View File
@@ -1,12 +1,6 @@
import { ResolveError, type Post, type Segment } from '../types.ts';
import { ResolveError, type Media, type Post, type Segment } from '../types.ts';
import { readOpenGraph } from '../browser/capture.ts';
import {
findCaption,
findMetaMedia,
mediaFromMetaNode,
mediaFromOpenGraph,
type MetaMediaNode,
} from './meta-media.ts';
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders, type MetaMediaNode } from './meta-media.ts';
import { hostMatcher } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts';
@@ -135,7 +129,19 @@ function segmentOf(post: ThreadsPost, isAnchor: boolean): Segment {
/** The floor, when the structured payload is not there to be read. */
async function fromOpenGraph(ctx: ResolveContext): Promise<Segment | undefined> {
const og = await readOpenGraph(ctx.page);
const media = mediaFromOpenGraph(og, REFERER);
const fetchHeaders = metaHeaders(REFERER);
const media: Media[] = [];
if (og['og:video']) {
media.push({
kind: 'video',
url: og['og:video'],
fetchHeaders,
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}),
});
} else if (og['og:image']) {
media.push({ kind: 'image', url: og['og:image'], fetchHeaders });
}
const text = og['og:description'];
if (media.length === 0 && !text) return undefined;
-1
View File
@@ -5,7 +5,6 @@
*/
const TRACKING_PARAMS = new Set([
'igsh', 'igshid', 'img_index', 'fbclid', 'gclid', 'mibextid',
'rdid', 'share_url', 'fs',
'ref_src', 'ref_url', 'ref', 'source', 'checksum', 'social_sharing',
's', 't', 'si', 'xmt', '_r', '_t', '_d',
'is_from_webapp', 'sender_device', 'sender_web_id', 'web_id',
+1 -1
View File
@@ -28,7 +28,7 @@ export function renderChallenge(challenge: Challenge, box: Box | undefined): str
<footer class="post__foot">
<div class="original">
<button type="button" class="copy" id="give-up">Give up and show me the link</button>
<a class="original__open" href="${challenge.originalUrl}" rel="noopener noreferrer nofollow" target="_blank" hidden>
<a class="original__open" href="${challenge.originalUrl}" rel="noopener noreferrer nofollow" target="_blank">
Open on ${challenge.platformLabel}
</a>
<p class="original__url"><code>${challenge.originalUrl}</code></p>
-8
View File
@@ -30,14 +30,6 @@ export function renderIndex(platforms: readonly PlatformSpec[]): string {
)}
</tbody>
</table>
<section class="picker" hidden>
<label class="picker__label" for="open-in">Open original links in</label>
<select class="picker__select" id="open-in"></select>
<p class="picker__note">
The rewrite rules catch the original link too, so in Safari Open on comes
straight back here. Another browser is a way out of that. Kept on this device.
</p>
</section>
</article>`;
return layout('antisocial', body);
+2 -8
View File
@@ -29,7 +29,6 @@ const ACCENT: Record<string, { color: string; monogram: string }> = {
x: { color: '#0f1419', monogram: '✕' },
threads: { color: '#3b3b3b', monogram: '@' },
instagram: { color: '#c13584', monogram: 'IG' },
facebook: { color: '#0866ff', monogram: 'f' },
tiktok: { color: '#fe2c55', monogram: '♪' },
bluesky: { color: '#0085ff', monogram: 'B' },
reddit: { color: '#ff4500', monogram: 'r' },
@@ -44,19 +43,14 @@ export function badge(platform: string, label: string): Raw {
}
/** The copy control, plus the URL itself so it is always selectable even if
* the clipboard API is unavailable.
*
* The open link ships hidden: followed in the browser the rewrite rules are
* installed in, it redirects straight back here, so it is only worth showing
* once a browser has been picked to hand it to. The script reveals it, and
* the href in the markup is the plain address it starts from. */
* the clipboard API is unavailable. */
export function originalUrlBlock(originalUrl: string, platformLabel: string): Raw {
return html`<div class="original">
<button type="button" class="copy" data-url="${originalUrl}">
<span class="copy__idle">Copy original link</span>
<span class="copy__done" hidden>Copied</span>
</button>
<a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank" hidden>Open on ${platformLabel}</a>
<a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank">Open on ${platformLabel}</a>
<p class="original__url"><code>${originalUrl}</code></p>
</div>`;
}
+22 -62
View File
@@ -13,20 +13,6 @@ import { escapeHtml, raw, type Raw } from './html.ts';
const REDDIT = 'https://www.reddit.com';
/**
* How an image in a comment becomes markup.
*
* Supplied by the caller rather than decided here, because the address has to
* go through the media proxy and this file knows nothing about that. Without
* one an image degrades to a link, which is what it was before.
*/
export type ImageRenderer = (url: string, alt: string) => string;
/** Worth showing as a picture rather than as a link to one. */
function looksLikeImage(url: string): boolean {
return /\.(jpe?g|png|gif|webp|avif)(\?|$)/i.test(url);
}
/** Absolute http(s) only. `javascript:` and friends never become links. */
function safeHref(url: string): string | undefined {
try {
@@ -60,69 +46,44 @@ function trimUrlTail(url: string): string {
const INLINE = new RegExp(
[
'`([^`\\n]+)`', // 1 code
// Before the link rule, or the `[` of an image matches as a link and
// leaves its `!` behind as text.
'!\\[([^\\]\\n]*)\\]\\(([^)\\s]+)\\)', // 2 alt, 3 src
'\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 4 label, 5 href
'\\*\\*([^*\\n]+)\\*\\*', // 6 strong
'~~([^~\\n]+)~~', // 7 strike
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 8 em with asterisks
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 9 em with underscores
'(https?://[^\\s<>]+)', // 10 bare url
// 11 the same thing with the scheme left off, which is how people type
// them. Narrow on purpose: a host, a path, and an image extension. Prose
// is full of dotted words, and `src/render/post.ts` must not become a
// link to a website in Tonga.
'(?<![\\w@/.])((?:[a-z0-9-]+\\.)+[a-z]{2,}/[^\\s<>]*\\.(?:jpe?g|png|gif|webp|avif)(?:\\?[^\\s<>]*)?)',
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 12 r/sub and u/name
'\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 2 label, 3 href
'\\*\\*([^*\\n]+)\\*\\*', // 4 strong
'~~([^~\\n]+)~~', // 5 strike
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 6 em with asterisks
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 7 em with underscores
'(https?://[^\\s<>]+)', // 8 bare url
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 9 r/sub and u/name
].join('|'),
'g',
);
/** One line of body text: escaped, with the inline constructs put back. */
function inline(text: string, image?: ImageRenderer): string {
function inline(text: string): string {
let out = '';
let cursor = 0;
for (const match of text.matchAll(INLINE)) {
const [whole, code, alt, src, label, href, strong, strike, emStar, emScore, url, schemeless,
subOrUser] = match;
const [whole, code, label, href, strong, strike, emStar, emScore, url, subOrUser] = match;
out += escapeHtml(text.slice(cursor, match.index));
cursor = match.index + whole.length;
if (code !== undefined) {
out += `<code>${escapeHtml(code)}</code>`;
} else if (src !== undefined) {
const safe = safeHref(src);
// Without a renderer to place it, an image is still a link to one.
out += safe ? (image ? image(safe, alt ?? '') : anchor(safe, alt || safe)) : escapeHtml(whole);
} else if (label !== undefined && href !== undefined) {
const safe = safeHref(href);
out += safe ? anchor(safe, label) : escapeHtml(whole);
} else if (strong !== undefined) {
out += `<strong>${inline(strong, image)}</strong>`;
out += `<strong>${inline(strong)}</strong>`;
} else if (strike !== undefined) {
out += `<del>${inline(strike, image)}</del>`;
out += `<del>${inline(strike)}</del>`;
} else if (emStar !== undefined || emScore !== undefined) {
out += `<em>${inline(emStar ?? emScore ?? '', image)}</em>`;
out += `<em>${inline(emStar ?? emScore ?? '')}</em>`;
} else if (url !== undefined) {
const trimmed = trimUrlTail(url);
const safe = safeHref(trimmed);
const tail = escapeHtml(url.slice(trimmed.length));
if (!safe) {
out += escapeHtml(whole);
} else if (image && looksLikeImage(trimmed)) {
// People paste the address of a picture and mean the picture. On
// Reddit that is most of what an image in a comment even is.
out += image(safe, '') + tail;
} else {
out += anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + tail;
}
} else if (schemeless !== undefined) {
// Assumed https: every host that serves these redirects to it anyway,
// and a picture is the one thing worth guessing a scheme for.
const safe = safeHref(`https://${schemeless}`);
out += safe ? (image ? image(safe, '') : anchor(safe, schemeless)) : escapeHtml(whole);
out += safe
? anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + escapeHtml(url.slice(trimmed.length))
: escapeHtml(whole);
} else if (subOrUser !== undefined) {
const path = subOrUser.startsWith('/') ? subOrUser : `/${subOrUser}`;
out += anchor(`${REDDIT}${path}`, subOrUser);
@@ -140,7 +101,7 @@ const NUMBERED = /^\s{0,3}\d+[.)]\s+/;
* from the reply to it, and a comment that loses that separation reads as
* though the commenter said both halves.
*/
function blocks(lines: string[], image?: ImageRenderer): string {
function blocks(lines: string[]): string {
let out = '';
let at = 0;
@@ -178,7 +139,7 @@ function blocks(lines: string[], image?: ImageRenderer): string {
if (/^\s*>/.test(line)) {
const body = takeWhile((l) => /^\s*>/.test(l));
// Nested, so a quote of a quote keeps its shape.
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')), image)}</blockquote>`;
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')))}</blockquote>`;
continue;
}
@@ -187,21 +148,20 @@ function blocks(lines: string[], image?: ImageRenderer): string {
const pattern = ordered ? NUMBERED : BULLET;
const items = takeWhile((l) => pattern.test(l));
const tag = ordered ? 'ol' : 'ul';
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''), image)}</li>`).join('')}</${tag}>`;
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''))}</li>`).join('')}</${tag}>`;
continue;
}
const paragraph = takeWhile(
(l) => l.trim() !== '' && !/^\s*>/.test(l) && !BULLET.test(l) && !NUMBERED.test(l) && !/^\s*```/.test(l),
);
out += `<p>${paragraph.map((l) => inline(l, image)).join('<br>')}</p>`;
out += `<p>${paragraph.map((l) => inline(l)).join('<br>')}</p>`;
}
return out;
}
/** Comment text, as safe markup. `image` places the pictures; without it
* they stay links, which is what they were before. */
export function renderMarkdown(text: string, image?: ImageRenderer): Raw {
return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n'), image));
/** Comment text, as safe markup. */
export function renderMarkdown(text: string): Raw {
return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n')));
}
+2 -38
View File
@@ -14,29 +14,11 @@ function aspect(item: Media): string {
return item.width && item.height ? `aspect-ratio: ${item.width} / ${item.height};` : '';
}
/**
* A stand-in poster carrying nothing but the video's shape.
*
* The ratio above is not enough on its own before the video has any data: a
* `<video>` in that state has a natural size of 300x150, and WebKit sizes it
* from that rather than from the ratio, so a portrait video sat in a squat
* landscape box until you pressed play and it snapped to shape. A video's
* size before its data arrives is its poster's, which makes an empty SVG of
* the right shape enough to put the box right, and as a data URI it costs no
* request. Only for a video the platform gave no poster for, since a real one
* already says the same thing.
*/
function placeholderPoster(item: Media): string | undefined {
if (!item.width || !item.height) return undefined;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${item.width}" height="${item.height}"/>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
function renderItem(item: Media, index: number): Raw {
const src = proxyUrlFor(item);
if (item.kind === 'video') {
const poster = item.poster ? proxyUrlFor(item.poster) : placeholderPoster(item);
const poster = item.poster ? proxyUrlFor(item.poster) : undefined;
return html`<figure class="item item--video" data-index="${index}">
<video
controls
@@ -163,22 +145,6 @@ function shortWhen(postedAt: string | undefined): Raw {
})}</time>`;
}
/**
* A picture inside a comment.
*
* Through the proxy like everything else a comment full of `preview.redd.it`
* addresses would otherwise have the viewer's browser fetch every one of them
* straight from Reddit, which is the thing this whole app exists to avoid.
*
* No dimensions to reserve space with: the size is in the payload but not in
* the Markdown, so these are capped by the stylesheet and load at whatever
* shape they are.
*/
function renderCommentImage(url: string, alt: string): string {
return html`<img class="c__img" src="${proxyUrlFor({ url })}" alt="${alt}" loading="lazy" decoding="async">`
.value;
}
/** Everything hanging off a comment, however deep. Shown only while it is
* collapsed, so what a fold is hiding is never a mystery. */
function descendantsOf(comment: Comment): number {
@@ -213,9 +179,7 @@ function renderComment(comment: Comment, depth: number): Raw {
}</span>`
: ''}
</summary>
${comment.text
? html`<div class="c__body">${renderMarkdown(comment.text, renderCommentImage)}</div>`
: ''}
${comment.text ? html`<div class="c__body">${renderMarkdown(comment.text)}</div>` : ''}
${comment.replies.length || comment.moreReplies
? html`<div class="c__replies">
${comment.replies.map((reply) => renderComment(reply, depth + 1))}
-1
View File
@@ -6,7 +6,6 @@ const PROFILE_BASE: Record<PlatformId, string> = {
x: 'https://x.com/',
threads: 'https://www.threads.com/@',
instagram: 'https://www.instagram.com/',
facebook: 'https://www.facebook.com/',
tiktok: 'https://www.tiktok.com/@',
bluesky: 'https://bsky.app/profile/',
reddit: 'https://www.reddit.com/user/',
+1 -2
View File
@@ -1,6 +1,5 @@
/** The platforms antisocial understands. */
export type PlatformId =
| 'x' | 'threads' | 'instagram' | 'facebook' | 'tiktok' | 'bluesky' | 'reddit';
export type PlatformId = 'x' | 'threads' | 'instagram' | 'tiktok' | 'bluesky' | 'reddit';
/**
* Something fetchable that lives on someone else's CDN.
-47
View File
@@ -1,47 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { browsersFor, detectOs, openUrlFor } from '../public/browsers.js';
const URL = 'https://www.instagram.com/p/ABC/?a=1&b=2';
test('each browser gets the scheme it actually answers to', () => {
assert.equal(openUrlFor(URL, 'chrome', 'macos'), 'googlechromes://www.instagram.com/p/ABC/?a=1&b=2');
assert.equal(openUrlFor(URL, 'chrome', 'ios'), 'googlechromes://www.instagram.com/p/ABC/?a=1&b=2');
assert.equal(openUrlFor('http://example.com/a', 'chrome', 'ios'), 'googlechrome://example.com/a');
// Edge folds the scheme into its own on iOS and prefixes it on macOS.
assert.equal(openUrlFor(URL, 'edge', 'macos'), `microsoft-edge:${URL}`);
assert.equal(openUrlFor(URL, 'edge', 'ios'), 'microsoft-edge-https://www.instagram.com/p/ABC/?a=1&b=2');
// The parameter form has to be encoded, or the original query string ends
// up read as the opener's own.
assert.equal(openUrlFor(URL, 'orion', 'macos'),
`orion://open-url?url=${encodeURIComponent(URL)}`);
assert.equal(openUrlFor(URL, 'firefox', 'ios'),
`firefox://open-url?url=${encodeURIComponent(URL)}`);
});
test('a browser with no scheme on this system keeps the plain link', () => {
// Firefox on macOS has none, and a dead scheme opens nothing at all --
// worse than opening in the wrong browser.
assert.equal(openUrlFor(URL, 'firefox', 'macos'), URL);
assert.equal(openUrlFor(URL, 'default', 'ios'), URL);
assert.equal(openUrlFor(URL, 'nonesuch', 'ios'), URL);
});
test('only ordinary web links are rewritten', () => {
assert.equal(openUrlFor('mailto:[email protected]', 'chrome', 'ios'), 'mailto:[email protected]');
assert.equal(openUrlFor('/reddit/r/a/comments/b', 'chrome', 'ios'), '/reddit/r/a/comments/b');
});
test('only the browsers reachable on that system are offered', () => {
assert.deepEqual(browsersFor('macos').map((b) => b.id), ['default', 'chrome', 'edge', 'orion']);
assert.deepEqual(browsersFor('ios').map((b) => b.id), ['default', 'chrome', 'edge', 'firefox', 'orion']);
});
test('an iPad is told from a Mac by its touch points, not its platform string', () => {
assert.equal(detectOs({ platform: 'iPhone', maxTouchPoints: 5 }), 'ios');
assert.equal(detectOs({ platform: 'MacIntel', maxTouchPoints: 5 }), 'ios');
assert.equal(detectOs({ platform: 'MacIntel', maxTouchPoints: 0 }), 'macos');
assert.equal(detectOs({}), 'macos');
});
-191
View File
@@ -1,191 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
findAuthor,
mediaFromAttachments,
mediaFromNode,
partsOfPost,
postIdFrom,
scrapeParts,
} from '../src/platforms/facebook.ts';
import { fixture } from './helpers.ts';
// Real captures of what a logged-out Facebook page ships, trimmed of the DASH
// manifests and tracking blobs the adapter never reads. Each is a different
// shape: a reel arrives with the next reels of the feed attached, a photo post
// keys itself by an opaque `pfbid` with no id in the address at all, and a
// video post has its pieces scattered over four separate payload blocks.
const REEL = 'https://www.facebook.com/reel/962551420197596/';
const PHOTO_POST =
'https://www.facebook.com/NASA/posts/pfbid02yzKA4Z5Wnep5xU3PLWyWgRuVebWg3UZoATsEH3wuZcKoZuiLSBL8rEkbe4qTZ65Jl';
const VIDEO_POST =
'https://www.facebook.com/NASA/videos/nancy-grace-roman-space-telescope-launch-trailer/1082585924135116/';
const reel = () => fixture<unknown[]>('facebook/reel.json');
const photoPost = () => fixture<unknown[]>('facebook/photo-post.json');
const videoPost = () => fixture<unknown[]>('facebook/video-post.json');
const scrape = (payloads: unknown[], id: string | undefined, url: string) =>
scrapeParts(partsOfPost(payloads, id, url));
test('a reel is read as the reel that was linked, not the feed around it', () => {
const post = scrape(reel(), '962551420197596', REEL);
assert.equal(post.author?.name, 'SpeechProf');
assert.equal(post.text, 'I need answers.');
assert.equal(post.media.length, 1);
assert.equal(post.media[0]?.kind, 'video');
});
test('the reels Facebook staples on are really in the payload', () => {
// Without this the test above proves nothing: the whole point is that the
// page carries other people's reels in the same shape as the linked one.
const others = JSON.stringify(reel()).match(/facebook\.com\\?\/reel\\?\/(\d+)/g) ?? [];
const ids = new Set(others.map((match) => /(\d+)/.exec(match)?.[1]));
ids.delete('962551420197596');
assert.ok(ids.size > 0, 'the fixture should carry recommended reels too');
});
test('a portrait reel keeps its shape, its poster and its length', () => {
const item = scrape(reel(), '962551420197596', REEL).media[0];
assert.equal(item?.width, 720);
assert.equal(item?.height, 1280);
assert.equal(item?.kind === 'video' ? item.durationSec : undefined, 15);
assert.match(
item?.kind === 'video' ? (item.poster?.url ?? '') : '',
/scontent-.*fbcdn\.net/,
);
});
test('a post whose address carries no id is found by its permalink', () => {
const post = scrape(photoPost(), undefined, PHOTO_POST);
assert.match(post.author?.name ?? '', /^NASA/);
assert.match(post.text ?? '', /^25 years ago/);
assert.equal(post.media[0]?.kind, 'image');
});
test('the client configuration shipped alongside the post is not read as the post', () => {
// With neither an id nor a matching permalink there is nothing to anchor
// on, and the page's own configuration — thousands of nodes carrying a
// `name` — is what a plain search finds first.
const post = scrape(photoPost(), undefined, 'https://www.facebook.com/somewhere/else');
assert.match(post.author?.name ?? '', /^NASA/);
});
test('a video post is assembled from the several blocks it is split over', () => {
const post = scrape(videoPost(), '1082585924135116', VIDEO_POST);
// The name, the vanity address and the picture arrive in different blocks
// from the file and the timestamp; all of it has to end up on one post.
assert.match(post.author?.name ?? '', /^NASA/);
assert.equal(post.author?.url, 'https://www.facebook.com/NASA');
assert.ok(post.author?.profile_picture?.uri, 'the avatar is in a block of its own');
assert.match(post.text ?? '', /Nancy Grace Roman/);
assert.equal(post.postedAt, '2026-08-28T23:23:27.000Z');
assert.equal(post.media[0]?.kind, 'video');
});
test('someone commenting under the post is not mistaken for its author', () => {
const payloads = videoPost();
assert.ok(
JSON.stringify(payloads).includes('Michael Hall'),
'the fixture should carry the comments, which is what makes this a trap',
);
const author = findAuthor(partsOfPost(payloads, '1082585924135116', VIDEO_POST));
assert.match(author?.name ?? '', /^NASA/);
});
test('a page that answers to a different post is not read at all', () => {
// Facebook serves a link to something it no longer has by quietly handing
// back something else — `/watch/<id>` for a video that is gone comes back
// as the Watch home page, feed and all. A page that names neither the id
// nor the address is a failure rather than whatever happened to be on it.
assert.deepEqual(
partsOfPost(reel(), '111111111111111', 'https://www.facebook.com/watch/111111111111111/'),
[],
);
});
test('an address with no id in it still falls back to the query results', () => {
// The strictness above only applies where there was an id to check: a
// `pfbid` permalink has none, and refusing those would refuse every post
// shared from a page.
assert.notDeepEqual(partsOfPost(photoPost(), undefined, 'https://www.facebook.com/elsewhere'), []);
});
test('the id is taken from wherever the address keeps it', () => {
const id = (url: string) => postIdFrom(new URL(url));
assert.equal(id('https://www.facebook.com/reel/962551420197596/'), '962551420197596');
assert.equal(id('https://www.facebook.com/photo/?fbid=1626865842142119&set=a.41'), '1626865842142119');
assert.equal(id('https://www.facebook.com/watch/?v=1082585924135116'), '1082585924135116');
assert.equal(id('https://www.facebook.com/NASA/videos/some-slug/1082585924135116/'), '1082585924135116');
assert.equal(id('https://www.facebook.com/groups/123456789/posts/987654321/'), '987654321');
assert.equal(id('https://www.facebook.com/NASA/posts/pfbid02yzKA4Z5'), undefined);
});
test('`id` on a permalink is the page, not the post, so it is left alone', () => {
assert.equal(
postIdFrom(new URL('https://www.facebook.com/permalink.php?story_fbid=222222222&id=999999999')),
'222222222',
);
assert.equal(postIdFrom(new URL('https://www.facebook.com/profile.php?id=999999999')), undefined);
});
test('the better of the two files Facebook offers is the one used', () => {
const media = mediaFromNode({
videoDeliveryLegacyFields: {
browser_native_sd_url: 'https://video.example/sd.mp4',
browser_native_hd_url: 'https://video.example/hd.mp4',
},
preferred_thumbnail: { image: { uri: 'https://image.example/poster.jpg' } },
});
assert.equal(media[0]?.url, 'https://video.example/hd.mp4');
assert.equal(media[0]?.kind === 'video' ? media[0].poster?.url : undefined,
'https://image.example/poster.jpg');
});
test('an empty `all_subattachments` is a single picture, not a carousel', () => {
// Facebook ships the key on every post, so its presence says nothing.
const media = mediaFromAttachments([
{
all_subattachments: { nodes: [] },
styles: { attachment: { media: { photo_image: { uri: 'https://image.example/one.jpg' } } } },
},
]);
assert.deepEqual(media.map((item) => item.url), ['https://image.example/one.jpg']);
});
test('a populated `all_subattachments` is every picture in the post', () => {
const media = mediaFromAttachments([
{
media: { photo_image: { uri: 'https://image.example/cover.jpg' } },
all_subattachments: {
nodes: [
{ media: { photo_image: { uri: 'https://image.example/1.jpg' } } },
{ media: { photo_image: { uri: 'https://image.example/2.jpg', width: 8, height: 6 } } },
],
},
},
]);
assert.deepEqual(media.map((item) => item.url), [
'https://image.example/1.jpg',
'https://image.example/2.jpg',
]);
assert.equal(media[1]?.width, 8);
});
test('every asset carries the headers the Facebook CDN is given', () => {
const post = scrape(reel(), '962551420197596', REEL);
const item = post.media[0];
assert.equal(item?.fetchHeaders?.['Referer'], 'https://www.facebook.com/');
assert.equal(item?.fetchHeaders?.['Origin'], 'https://www.facebook.com');
});
File diff suppressed because it is too large Load Diff
-775
View File
@@ -1,775 +0,0 @@
[
{
"require": [
[
"ScheduledServerJS",
"handle",
null,
[
{
"__bbox": {
"define": [
[
"VideoPlayerShakaPerformanceLoggerConfig",
[],
{
"isSampled": false
},
1760
]
],
"require": [
[
"useVideoPlayerShakaPerformanceLoggerRelayImpl_init$normalization.graphql"
],
[
"VideoPlayerShakaPerformanceLogger"
],
[
"useVideoPlayerShakaPerformanceLoggerBuilder_init$normalization.graphql"
],
[
"VideoPlayerShakaPerformanceLoggerBuilder"
],
[
"emptyFunction",
"thatReturns",
[
"RequireDeferredReference"
],
[
[
{
"__dr": "useVideoPlayerShakaPerformanceLoggerRelayImpl_init$normalization.graphql"
},
{
"__dr": "VideoPlayerShakaPerformanceLogger"
},
{
"__dr": "useVideoPlayerShakaPerformanceLoggerBuilder_init$normalization.graphql"
},
{
"__dr": "VideoPlayerShakaPerformanceLoggerBuilder"
}
]
]
],
[
"RelayPrefetchedStreamCache",
"next",
[],
[
"adp_FBReelsRootWithEntrypointQueryRelayPreloader_6aa55a2ad72a56521899979",
{
"__bbox": {
"complete": false,
"result": {
"data": {
"video": {
"creation_story": {
"short_form_video_context": {
"self_view_boost": null,
"video": {
"id": "962551420197596",
"first_frame_thumbnail": "https://scontent-lga3-3.xx.fbcdn.net/v/t15.5256-10/789978403_1809099130521122_6040441097728922839_n.jpg?stp=dst-jpg_tt6&cstp=mx720x1280&ctp=s720x1280&_nc_cat=102&ccb=1-7&_nc_sid=5fad0e&_nc_ohc=DvPOdqdEQNEQ7kNvwHnYHcF&_nc_oc=AdpAQYs6MUrh7K_OCrf910OmxkBIsJIqpTHeBaiPySfFaTTDWaTVE8phgrp1f0D9TZ0&_nc_zt=23&_nc_ht=scontent-lga3-3.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKEdQnt-qQQOSPFDO8-hSInQQiX2bH18d8eyb1VYXJLCQ&oe=6AAB29D3",
"__typename": "Video",
"playable_duration_in_ms": 14952,
"embeddable": true,
"owner": {
"__typename": "User",
"id": "100076751910580"
},
"audio_availability": "AVAILABLE",
"seo_llm_content": null
},
"if_should_change_url_for_reels": null,
"shareable_url": "https://www.facebook.com/reel/962551420197596",
"playback_video": {
"aspect_ratio": 0.5625,
"height": 1280,
"width": 720,
"length_in_second": 14.952,
"id": "962551420197596",
"dubbed_track_mapping": [],
"thumbnailImage": {
"uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t15.5256-10/789706769_1556749619267719_4416772935294433768_n.jpg?stp=dst-jpg_tt6&cstp=mx720x1280&ctp=s960x960&_nc_cat=101&ccb=1-7&_nc_sid=5fad0e&_nc_ohc=YIFZQCxz3MQQ7kNvwEZs12e&_nc_oc=AdpS4HDG305iIN7lMtXk_yVOZzhXmR2k1smCnFXHeEj-JhUnua_ywiQIxb6anRE6yhY&_nc_zt=23&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQJmcHNS0DS_a1T6Tsxz072XU2rRAD5ux2IaGXJcJmJJvQ&oe=6AAB402F"
},
"animated_image_caption": null,
"broadcaster_origin": null,
"broadcast_id": null,
"broadcast_status": null,
"is_live_streaming": false,
"is_live_trace_enabled": false,
"is_looping": true,
"is_video_broadcast": false,
"is_podcast_video": false,
"loop_count": 6,
"is_spherical": false,
"is_spherical_enabled": true,
"unsupported_browser_message": null,
"pmv_metadata": null,
"latency_sensitive_config": null,
"live_playback_instrumentation_configs": null,
"is_ncsr": false,
"permalink_url": "https://www.facebook.com/reel/962551420197596/",
"seo_web_crawler_lookaside_url": null,
"video_status_type": "OK",
"can_use_oz": true,
"min_quality_preference": null,
"spherical_video_fallback_urls": null,
"comet_video_player_nextgendash_availability": "AVAILABLE",
"videoDeliveryLegacyFields": {
"dash_manifest_url": "https://www.facebook.com/dash_mpd_debug.mpd?v=962551420197596&dummy=.mpd",
"browser_native_sd_url": "https://video-lga3-1.xx.fbcdn.net/o1/v/t2/f2/m412/AQNPfClq4XBdFUYWXh5JJcADVGPYjlQAyyfGGsMvzGU39oqvYXd-DobOpXNWxfUxl24ISRJrHRyGe-PuNwE3KWVHDREA6ziGSzebPeUcxQ.mp4?_nc_cat=110&_nc_sid=8bf8fe&_nc_ht=video-lga3-1.xx.fbcdn.net&_nc_ohc=zQ3ivCtauyoQ7kNvwEidn40&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuMzYwLnN2ZV9zZCIsInhwdl9hc3NldF9pZCI6MzEwMjgwMjI4MzI1NTg0MSwiYXNzZXRfYWdlX2RheXMiOjEyLCJ2aV91c2VjYXNlX2lkIjoxMDEyMiwiZHVyYXRpb25fcyI6MTQsInVybGdlbl9zb3VyY2UiOiJ3d3cifQ%3D%3D&ccb=17-1&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQKAmLf2hie1QceS3NEtBpozi3IzBvx05iysQQ4udnzEmA&oe=6AAB36F8&bitrate=250233&tag=sve_sd",
"browser_native_hd_url": "https://video-lga3-3.xx.fbcdn.net/o1/v/t2/f2/m366/AQP2uXO3e__FC55WpiTUHKGsjhpA8irwEiU9M-ep75IQJXDPWw-rcn5BOLIh37rWgi0F66T2tvsq-M26duEVA7YNjcJxc5vC0RRKAqcBMLLWYw.mp4?_nc_cat=106&_nc_sid=5e9851&_nc_ht=video-lga3-3.xx.fbcdn.net&_nc_ohc=yuT4HCi5mtMQ7kNvwGVmgn8&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuNzIwLmRhc2hfaDI2NC1iYXNpYy1nZW4yXzcyMHAiLCJ4cHZfYXNzZXRfaWQiOjMxMDI4MDIyODMyNTU4NDEsImFzc2V0X2FnZV9kYXlzIjoxMiwidmlfdXNlY2FzZV9pZCI6MTAxMjIsImR1cmF0aW9uX3MiOjE0LCJ1cmxnZW5fc291cmNlIjoid3d3In0%3D&ccb=17-1&vs=6c8f2d6921f22687&_nc_vs=HBksFQIYRWZiX2VwaGVtZXJhbC9GMjQyRTUwQzVDQ0U4RkE2QTE3NjExQzU4RkU4QzZBMl9tdF8xX3ZpZGVvX2Rhc2hpbml0Lm1wNBUAAsgBEgAVAhhAZmJfcGVybWFuZW50Lzc5NDUxM0FGMjVBOUJBMDgyQTBGMUJFODFCRDk2NEFFX2F1ZGlvX2Rhc2hpbml0Lm1wNBUCAsgBEgAoABgAGwKIB3VzZV9vaWwBMRJwcm9ncmVzc2l2ZV9yZWNpcGUBMRUAACbC4La-7v6CCxUCKAJDMywXQC3dsi0OVgQYGWRhc2hfaDI2NC1iYXNpYy1nZW4yXzcyMHARAHUCZZSeAQA&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQKAQHnGYXQYLb6DgP81xMw4ISjcqBkBSHSu0taJI5ciGg&oe=6AAB3B02&bitrate=941272&tag=dash_h264-basic-gen2_720p",
"id": "962551420197596"
},
"is_gaming_video": false,
"is_latency_menu_enabled": false,
"fbls_tier": null,
"is_latency_sensitive_broadcast": false,
"video_player_shaka_performance_logger_should_sample": false,
"autoplay_gating_result": "gatekeeper",
"viewer_autoplay_setting": "default_autoplay",
"can_autoplay": false,
"drm_info": "{\"video_license_uri_map\":{},\"graph_api_video_license_uri\":null,\"fairplay_cert\":null,\"widevine_cert\":\"CsECCAMSEBcFuRfMEgSGiwYzOi93KowYgrSCkgUijgIwggEKAoIBAQCZ7Vs7Mn2rXiTvw7YqlbWYUgrVvMs3UD4GRbgU2Ha430BRBEGtjOOtsRu4jE5yWl5KngeVKR1YWEAjp+GvDjipEnk5MAhhC28VjIeMfiG\\/+\\/7qd+EBnh5XgeikX0YmPRTmDoBYqGB63OBPrIRXsTeo1nzN6zNwXZg6IftO7L1KEMpHSQykfqpdQ4IY3brxyt4zkvE9b\\/tkQv0x4b9AsMYE0cS6TJUgpL+X7r1gkpr87vVbuvVk4tDnbNfFXHOggrmWEguDWe3OJHBwgmgNb2fG2CxKxfMTRJCnTuw3r0svAQxZ6ChD4lgvC2ufXbD8Xm7fZPvTCLRxG88SUAGcn1oJAgMBAAE6FGxpY2Vuc2Uud2lkZXZpbmUuY29tEoADrjRzFLWoNSl\\/JxOI+3u4y1J30kmCPN3R2jC5MzlRHrPMveoEuUS5J8EhNG79verJ1BORfm7BdqEEOEYKUDvBlSubpOTOD8S\\/wgqYCKqvS\\/zRnB3PzfV0zKwo0bQQQWz53ogEMBy9szTK\\/NDUCXhCOmQuVGE98K\\/PlspKkknYVeQrOnA+8XZ\\/apvTbWv4K+drvwy6T95Z0qvMdv62Qke4XEMfvKUiZrYZ\\/DaXlUP8qcu9u\\/r6DhpV51Wjx7zmVflkb1gquc9wqgi5efhn9joLK3\\/bNixbxOzVVdhbyqnFk8ODyFfUnaq3fkC3hR3f0kmYgI41sljnXXjqwMoW9wRzBMINk+3k6P8cbxfmJD4\\/Paj8FwmHDsRfuoI6Jj8M76H3CTsZCZKDJjM3BQQ6Kb2m+bQ0LMjfVDyxoRgvfF\\/\\/M\\/EEkPrKWyU2C3YBXpxaBquO4C8A0ujVmGEEqsxN1HX9lu6c5OMm8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0\"}",
"p2p_settings": null,
"audio_settings": null,
"captions_settings": null,
"broadcast_low_latency_config": null,
"audio_availability": "AVAILABLE",
"muted_segments": [],
"spherical_video_renderer": null,
"preferred_thumbnail": {
"image": {
"uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t15.5256-10/789706769_1556749619267719_4416772935294433768_n.jpg?stp=dst-jpg_tt6&cstp=mx720x1280&ctp=s960x960&_nc_cat=101&ccb=1-7&_nc_sid=50ce42&_nc_ohc=YIFZQCxz3MQQ7kNvwEZs12e&_nc_oc=AdpS4HDG305iIN7lMtXk_yVOZzhXmR2k1smCnFXHeEj-JhUnua_ywiQIxb6anRE6yhY&_nc_zt=23&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQJmcHNS0DS_a1T6Tsxz072XU2rRAD5ux2IaGXJcJmJJvQ&oe=6AAB402F"
},
"id": "1605853321074011"
},
"video_imf_data": null,
"playback_language_on_comet": null,
"warning_screen_renderer": null,
"cix_screen": null
},
"video_owner": {
"__typename": "User",
"id": "100076751910580",
"__isActor": "User",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof",
"displayPicture": {
"uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s40x40&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C"
},
"should_show_reels_follow_back_cta": false,
"subscribe_status": "CANNOT_SUBSCRIBE",
"delegate_page": {
"id": "103285455517243",
"uri_token": "thespeechprof",
"woodhenge_creator_info": {
"viewer_entrypoint_eligibility": {
"is_eligible": true
},
"id": "272843125228141:FubsCreatorInfo",
"viewer_active_subscription_entitlement": null
}
}
},
"fb_shorts_reshare_context": {
"is_reshare": false,
"reshare_creator": {
"__typename": "User",
"__isActor": "User",
"id": "100076751910580",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof"
}
},
"remix_info": {
"is_remixable": true,
"status": "DISABLED"
},
"video_owner_type": "FACEBOOK_USER",
"soundtrack_info": {
"id": "38012539085060844",
"type": "CUSTOM_AUDIO"
},
"track_title": "SpeechProf · Original audio",
"music_album_art_uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s64x64&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C",
"is_original_audio_on_facebook": true
},
"id": "UzpfSTEwMDA3Njc1MTkxMDU4MDpWSzo5NjI1NTE0MjAxOTc1OTY=",
"sponsored_data": null,
"brand_safety_data": null,
"post_id": "1101740529061001",
"creation_time": 1788104325,
"unpublished_content_type": "PUBLISHED",
"scheduled_publish_time": null,
"sponsor_relationship": 0,
"branded_content_post_info": null,
"creator_collaboration": null,
"media_attribution_elements": [],
"attachments": [
{
"media": {
"__typename": "Video",
"is_made_with_meta_ai_app": false,
"__isNode": "Video",
"id": "962551420197596",
"video_delivery_response": {
"transcript_urls": [],
"id": "962551420197596"
}
},
"call_to_action_renderer": null
}
],
"can_viewer_delete": false,
"can_viewer_edit": false,
"can_viewer_cancel_collaboration_invite": false,
"can_viewer_remove_collaborator": false,
"can_viewer_see_collaboration_invite": false,
"legal_reporting_cta_type": null,
"legal_reporting_uri": null,
"to": null,
"video": {
"id": "962551420197596",
"owner": {
"__typename": "User",
"id": "100076751910580"
}
},
"post_collaboration": null,
"can_viewer_remove_self_as_collaborator": false,
"if_viewer_can_see_stars_toggle_menu_option": null,
"transparency_ad_info": {
"menu_label": "Ad info",
"should_display_ad_info": false
},
"debug_info": null,
"inform_treatment_for_community_notes": null,
"message": {
"text": "I need answers.",
"ranges": []
},
"translated_message_for_viewer": null,
"contextualElementShortFormContext": {
"video_label": null
}
},
"id": "962551420197596"
},
"viewer": {
"actor": {
"__typename": "LoggedOutUser",
"id": "0"
},
"lasso_blue_feed": {
"edges": [
{
"node": {
"__typename": "Story",
"short_form_video_context": {
"self_view_boost": null,
"video": {
"id": "1721985652466360",
"first_frame_thumbnail": "https://scontent-lga3-2.xx.fbcdn.net/v/t15.5256-10/755945469_1271098651617653_7681040076315983851_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=105&ccb=1-7&_nc_sid=b5ba86&_nc_ohc=5cMmm9pIok4Q7kNvwEzT1-_&_nc_oc=AdrC14K89EUG1eO6WsgKZ7T0H3PoqbkQVsGgORdcqOrB5EbSyFcxt0q3XqKPbmD9o6g&_nc_zt=23&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQIQeJM0ARbUsTVHCMOBhG4939d2FggfbBg5YX45AGDAtA&oe=6AAB1721",
"__typename": "Video",
"playable_duration_in_ms": 107233,
"embeddable": true,
"owner": {
"__typename": "User",
"id": "100076751910580"
},
"audio_availability": "AVAILABLE",
"seo_llm_content": null
},
"if_should_change_url_for_reels": {
"shareable_url": "https://www.facebook.com/reel/1721985652466360"
},
"shareable_url": "https://www.facebook.com/reel/1721985652466360",
"playback_video": {
"aspect_ratio": 0.5625,
"height": 1920,
"width": 1080,
"length_in_second": 107.233,
"id": "1721985652466360",
"dubbed_track_mapping": [
{
"audio_lang": "en",
"dubbing_type": "NONE"
}
],
"thumbnailImage": {
"uri": "https://scontent-lga3-3.xx.fbcdn.net/v/t15.5256-10/755514381_1689451229009756_2858366957534701310_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=104&ccb=1-7&_nc_sid=c44d43&_nc_ohc=f7YWmE4qvMYQ7kNvwG8Bmk2&_nc_oc=Adqe-TjWeH2ITkplOCr9oA3aHX5XUhnuZYy7MioMUnoaRIoTagnhs39xaOoGIahnrsc&_nc_zt=23&_nc_ht=scontent-lga3-3.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQIvEn9kMBhuPSZIXhuG7InSXTDNN2zCAHFfj-MXFkILew&oe=6AAB265D"
},
"animated_image_caption": null,
"broadcaster_origin": null,
"broadcast_id": null,
"broadcast_status": null,
"is_live_streaming": false,
"is_live_trace_enabled": false,
"is_looping": false,
"is_video_broadcast": false,
"is_podcast_video": false,
"loop_count": 0,
"is_spherical": false,
"is_spherical_enabled": true,
"unsupported_browser_message": null,
"pmv_metadata": null,
"latency_sensitive_config": null,
"live_playback_instrumentation_configs": null,
"is_ncsr": false,
"permalink_url": "https://www.facebook.com/reel/1721985652466360/",
"seo_web_crawler_lookaside_url": null,
"video_status_type": "OK",
"can_use_oz": true,
"min_quality_preference": null,
"spherical_video_fallback_urls": null,
"comet_video_player_nextgendash_availability": "AVAILABLE",
"videoDeliveryLegacyFields": {
"dash_manifest_url": "https://www.facebook.com/dash_mpd_debug.mpd?v=1721985652466360&dummy=.mpd",
"browser_native_sd_url": "https://video-lga3-2.xx.fbcdn.net/o1/v/t2/f2/m412/AQOHWkcH4C7iUUWXN0tX5u0LJNAHnx3OtpwNMi6E63W4IfnT0T2LXEH6jmsxvG2a_mlrrKP2D0DbBcSW-3m-5fcz6AUQNNn7ui5EjmStQw.mp4?_nc_cat=105&_nc_sid=8bf8fe&_nc_ht=video-lga3-2.xx.fbcdn.net&_nc_ohc=G6DkTtm4mFkQ7kNvwHMHbsC&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuMzYwLnN2ZV9zZCIsInhwdl9hc3NldF9pZCI6MjkwMTQzNjk3MzUzNTkyNCwiYXNzZXRfYWdlX2RheXMiOjQ5LCJ2aV91c2VjYXNlX2lkIjoxMDgyNSwiZHVyYXRpb25fcyI6MTA3LCJ1cmxnZW5fc291cmNlIjoid3d3In0%3D&ccb=17-1&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQIhF8WnKBSuiVFX9HYqM2qgXgxP-mCS_6UrSYY4jiZkEA&oe=6AAB2249&bitrate=338983&tag=sve_sd",
"browser_native_hd_url": "https://video-lga3-1.xx.fbcdn.net/o1/v/t2/f2/m412/AQNyuAvPxz3Ft8_ik8vpXP2aUhrMhSpSQAg5o4N85dqWAsqVeIJe91ZIVkxfEhCu4vH3IvSJFAbVegkaGzxCelTdgxWQYdbJAicJKZnY4YYUtw.mp4?_nc_cat=103&_nc_sid=5e9851&_nc_ht=video-lga3-1.xx.fbcdn.net&_nc_ohc=raTcNflJNr0Q7kNvwFlUu9s&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuMTA4MC5kYXNoX2gyNjQtYmFzaWMtZ2VuMl8xMDgwcCIsInhwdl9hc3NldF9pZCI6MjkwMTQzNjk3MzUzNTkyNCwiYXNzZXRfYWdlX2RheXMiOjQ5LCJ2aV91c2VjYXNlX2lkIjoxMDgyNSwiZHVyYXRpb25fcyI6MTA3LCJ1cmxnZW5fc291cmNlIjoid3d3In0%3D&ccb=17-1&vs=70ff53297466a1d2&_nc_vs=HBksFQIYRWZiX3Blcm1hbmVudC9CNTQ4NUVGMUM4NjlENjFBMjZERUUwMUY3M0U5QjVCMV9tdF8xX3ZpZGVvX2Rhc2hpbml0Lm1wNBUAAsgBEgAVAhhAZmJfcGVybWFuZW50L0Y3NDM0MDZBMDMyRTlEOERGQzQwRTBGMUJEQTA2NDgxX2F1ZGlvX2Rhc2hpbml0Lm1wNBUCAsgBEgAoABgAGwKIB3VzZV9vaWwBMRJwcm9ncmVzc2l2ZV9yZWNpcGUBMRUAACboyqqC7rWnChUCKAJDMywXQFrO6XjU_fQYGmRhc2hfaDI2NC1iYXNpYy1nZW4yXzEwODBwEQB1AmWSqQEA&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQIDxdABoLhIUTddsoY3e8lEb75XZeoo2nzFYg50CsGZRw&oe=6AAB2D71&bitrate=2157493&tag=dash_h264-basic-gen2_1080p",
"id": "1721985652466360"
},
"is_gaming_video": false,
"is_latency_menu_enabled": false,
"fbls_tier": null,
"is_latency_sensitive_broadcast": false,
"video_player_shaka_performance_logger_should_sample": false,
"autoplay_gating_result": "gatekeeper",
"viewer_autoplay_setting": "default_autoplay",
"can_autoplay": false,
"drm_info": "{\"video_license_uri_map\":{},\"graph_api_video_license_uri\":null,\"fairplay_cert\":null,\"widevine_cert\":\"CsECCAMSEBcFuRfMEgSGiwYzOi93KowYgrSCkgUijgIwggEKAoIBAQCZ7Vs7Mn2rXiTvw7YqlbWYUgrVvMs3UD4GRbgU2Ha430BRBEGtjOOtsRu4jE5yWl5KngeVKR1YWEAjp+GvDjipEnk5MAhhC28VjIeMfiG\\/+\\/7qd+EBnh5XgeikX0YmPRTmDoBYqGB63OBPrIRXsTeo1nzN6zNwXZg6IftO7L1KEMpHSQykfqpdQ4IY3brxyt4zkvE9b\\/tkQv0x4b9AsMYE0cS6TJUgpL+X7r1gkpr87vVbuvVk4tDnbNfFXHOggrmWEguDWe3OJHBwgmgNb2fG2CxKxfMTRJCnTuw3r0svAQxZ6ChD4lgvC2ufXbD8Xm7fZPvTCLRxG88SUAGcn1oJAgMBAAE6FGxpY2Vuc2Uud2lkZXZpbmUuY29tEoADrjRzFLWoNSl\\/JxOI+3u4y1J30kmCPN3R2jC5MzlRHrPMveoEuUS5J8EhNG79verJ1BORfm7BdqEEOEYKUDvBlSubpOTOD8S\\/wgqYCKqvS\\/zRnB3PzfV0zKwo0bQQQWz53ogEMBy9szTK\\/NDUCXhCOmQuVGE98K\\/PlspKkknYVeQrOnA+8XZ\\/apvTbWv4K+drvwy6T95Z0qvMdv62Qke4XEMfvKUiZrYZ\\/DaXlUP8qcu9u\\/r6DhpV51Wjx7zmVflkb1gquc9wqgi5efhn9joLK3\\/bNixbxOzVVdhbyqnFk8ODyFfUnaq3fkC3hR3f0kmYgI41sljnXXjqwMoW9wRzBMINk+3k6P8cbxfmJD4\\/Paj8FwmHDsRfuoI6Jj8M76H3CTsZCZKDJjM3BQQ6Kb2m+bQ0LMjfVDyxoRgvfF\\/\\/M\\/EEkPrKWyU2C3YBXpxaBquO4C8A0ujVmGEEqsxN1HX9lu6c5OMm8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0\"}",
"p2p_settings": null,
"audio_settings": null,
"captions_settings": null,
"broadcast_low_latency_config": null,
"audio_availability": "AVAILABLE",
"muted_segments": [],
"spherical_video_renderer": null,
"preferred_thumbnail": {
"image": {
"uri": "https://scontent-lga3-3.xx.fbcdn.net/v/t15.5256-10/755514381_1689451229009756_2858366957534701310_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=104&ccb=1-7&_nc_sid=50ce42&_nc_ohc=f7YWmE4qvMYQ7kNvwG8Bmk2&_nc_oc=Adqe-TjWeH2ITkplOCr9oA3aHX5XUhnuZYy7MioMUnoaRIoTagnhs39xaOoGIahnrsc&_nc_zt=23&_nc_ht=scontent-lga3-3.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQIvEn9kMBhuPSZIXhuG7InSXTDNN2zCAHFfj-MXFkILew&oe=6AAB265D"
},
"id": "2901437906869164"
},
"video_imf_data": null,
"playback_language_on_comet": null,
"warning_screen_renderer": null,
"cix_screen": null
},
"video_owner": {
"__typename": "User",
"id": "100076751910580",
"__isActor": "User",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof",
"displayPicture": {
"uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s40x40&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C"
},
"should_show_reels_follow_back_cta": false,
"subscribe_status": "CANNOT_SUBSCRIBE",
"delegate_page": {
"id": "103285455517243",
"uri_token": "thespeechprof",
"woodhenge_creator_info": {
"viewer_entrypoint_eligibility": {
"is_eligible": true
},
"id": "272843125228141:FubsCreatorInfo",
"viewer_active_subscription_entitlement": null
}
}
},
"fb_shorts_reshare_context": {
"is_reshare": false,
"reshare_creator": {
"__typename": "User",
"__isActor": "User",
"id": "100076751910580",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof"
}
},
"remix_info": {
"is_remixable": false,
"status": "DISABLED"
},
"video_owner_type": "FACEBOOK_USER",
"soundtrack_info": {
"id": "28167383946201489",
"type": "CUSTOM_AUDIO"
},
"track_title": "SpeechProf · Original audio",
"music_album_art_uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s64x64&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C",
"is_original_audio_on_facebook": true
},
"id": "UzpfSTEwMDA3Njc1MTkxMDU4MDpWSzoxNzIxOTg1NjUyNDY2MzYw",
"sponsored_data": null,
"brand_safety_data": null,
"post_id": "1069408425627545",
"creation_time": 1784917622,
"unpublished_content_type": "PUBLISHED",
"scheduled_publish_time": null,
"sponsor_relationship": 0,
"branded_content_post_info": null,
"creator_collaboration": null,
"media_attribution_elements": [],
"attachments": [
{
"media": {
"__typename": "Video",
"is_made_with_meta_ai_app": false,
"__isNode": "Video",
"id": "1721985652466360",
"video_delivery_response": {
"transcript_urls": [],
"id": "1721985652466360"
}
},
"call_to_action_renderer": null
}
],
"can_viewer_delete": false,
"can_viewer_edit": false,
"can_viewer_cancel_collaboration_invite": false,
"can_viewer_remove_collaborator": false,
"can_viewer_see_collaboration_invite": false,
"legal_reporting_cta_type": null,
"legal_reporting_uri": null,
"to": null,
"video": {
"id": "1721985652466360",
"owner": {
"__typename": "User",
"id": "100076751910580"
}
},
"post_collaboration": null,
"can_viewer_remove_self_as_collaborator": false,
"if_viewer_can_see_stars_toggle_menu_option": null,
"transparency_ad_info": {
"menu_label": "Ad info",
"should_display_ad_info": false
},
"debug_info": null,
"inform_treatment_for_community_notes": null,
"message": {
"text": "They wanted the SAVE Act. They arent going to get it.",
"ranges": []
},
"translated_message_for_viewer": null,
"contextualElementShortFormContext": {
"video_label": null
},
"__isNode": "Story"
},
"cursor": "0",
"id": "UzpfSTEwMDA3Njc1MTkxMDU4MDpWSzoxNzIxOTg1NjUyNDY2MzYw"
},
{
"node": {
"__typename": "Story",
"short_form_video_context": {
"self_view_boost": null,
"video": {
"id": "4394633517486098",
"first_frame_thumbnail": "https://scontent-lga3-1.xx.fbcdn.net/v/t15.5256-10/752380652_1765886184766289_5936699386843725422_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=111&ccb=1-7&_nc_sid=b5ba86&_nc_ohc=mwRXs8tE_5oQ7kNvwEgO6W7&_nc_oc=Adr6TYRYlu6q7xQSxkujTNt9dpCZrTybcXtIlM3oU0CqfvcDeWo73h315beqL_9np1o&_nc_zt=23&_nc_ht=scontent-lga3-1.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKndI8uzSgZXENjW1eoBo_E4cF2TfD9-1Mq_v0JFBsCqg&oe=6AAB322D",
"__typename": "Video",
"playable_duration_in_ms": 86233,
"embeddable": true,
"owner": {
"__typename": "User",
"id": "100076751910580"
},
"audio_availability": "AVAILABLE",
"seo_llm_content": null
},
"if_should_change_url_for_reels": {
"shareable_url": "https://www.facebook.com/reel/4394633517486098"
},
"shareable_url": "https://www.facebook.com/reel/4394633517486098",
"playback_video": {
"aspect_ratio": 0.5625,
"height": 1920,
"width": 1080,
"length_in_second": 86.233,
"id": "4394633517486098",
"dubbed_track_mapping": [
{
"audio_lang": "en",
"dubbing_type": "NONE"
}
],
"thumbnailImage": {
"uri": "https://scontent-lga3-1.xx.fbcdn.net/v/t15.5256-10/753306317_2059899598252019_7051428696037703086_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=110&ccb=1-7&_nc_sid=c44d43&_nc_ohc=fFK4qXTnhggQ7kNvwFoTF67&_nc_oc=Adpv6Qoge3bNmPKNIgAXN0RllPhOMDn0nss-rohiTyvwYOtqLQgnEd0BY6Nifk2O4h0&_nc_zt=23&_nc_ht=scontent-lga3-1.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQJEw5D-XUGzIDqfRvd3PYV2VHJbnaJgnBz6niOzlyw-Jw&oe=6AAB2BA6"
},
"animated_image_caption": null,
"broadcaster_origin": null,
"broadcast_id": null,
"broadcast_status": null,
"is_live_streaming": false,
"is_live_trace_enabled": false,
"is_looping": false,
"is_video_broadcast": false,
"is_podcast_video": false,
"loop_count": 0,
"is_spherical": false,
"is_spherical_enabled": true,
"unsupported_browser_message": null,
"pmv_metadata": null,
"latency_sensitive_config": null,
"live_playback_instrumentation_configs": null,
"is_ncsr": false,
"permalink_url": "https://www.facebook.com/reel/4394633517486098/",
"seo_web_crawler_lookaside_url": null,
"video_status_type": "OK",
"can_use_oz": true,
"min_quality_preference": null,
"spherical_video_fallback_urls": null,
"comet_video_player_nextgendash_availability": "AVAILABLE",
"videoDeliveryLegacyFields": {
"dash_manifest_url": "https://www.facebook.com/dash_mpd_debug.mpd?v=4394633517486098&dummy=.mpd",
"browser_native_sd_url": "https://video-lga3-2.xx.fbcdn.net/o1/v/t2/f2/m412/AQPnLuuqWCAlF2rQjRjOUp3m3VTZ2mKlfyx34GzbrXkpzS-7xlZIUcnpIF7ovflH8ZDoAUNMDVYUHPN2tp3L8DgkuT2Wj7KMnX_-4oRTag.mp4?_nc_cat=101&_nc_sid=8bf8fe&_nc_ht=video-lga3-2.xx.fbcdn.net&_nc_ohc=qJZTORkpUigQ7kNvwGyWZgR&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuMzYwLnN2ZV9zZCIsInhwdl9hc3NldF9pZCI6MjI5OTE0Mjg0NDIyMjk1MiwiYXNzZXRfYWdlX2RheXMiOjUxLCJ2aV91c2VjYXNlX2lkIjoxMDgyNSwiZHVyYXRpb25fcyI6ODYsInVybGdlbl9zb3VyY2UiOiJ3d3cifQ%3D%3D&ccb=17-1&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQI1fcGOS6mP_a9FADMRZvzrbLZiuiq9eLmnIu6eBNS76Q&oe=6AAB2335&bitrate=330175&tag=sve_sd",
"browser_native_hd_url": "https://video-lga3-3.xx.fbcdn.net/o1/v/t2/f2/m412/AQOZ7_1RxOwju3Gc9BBoYUpXDjf4Nkm1yjDn_rRmpt5ZnY7T97JiqH1x6lvwhf-tRSC2DPnbfs9vqgzrQkBg0d2ido38yLimT-wnUWi8NL--8A.mp4?_nc_cat=104&_nc_sid=5e9851&_nc_ht=video-lga3-3.xx.fbcdn.net&_nc_ohc=424Sgs3YgSUQ7kNvwHrS5PP&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5GQUNFQk9PSy4uQzMuMTA4MC5kYXNoX2gyNjQtYmFzaWMtZ2VuMl8xMDgwcCIsInhwdl9hc3NldF9pZCI6MjI5OTE0Mjg0NDIyMjk1MiwiYXNzZXRfYWdlX2RheXMiOjUxLCJ2aV91c2VjYXNlX2lkIjoxMDgyNSwiZHVyYXRpb25fcyI6ODYsInVybGdlbl9zb3VyY2UiOiJ3d3cifQ%3D%3D&ccb=17-1&vs=3ddc452b425a3aff&_nc_vs=HBksFQIYRWZiX3Blcm1hbmVudC84MjQ2QTE0NzlGREY0ODM4NjlGQ0VFOEM1NDFDMzg5Nl9tdF8xX3ZpZGVvX2Rhc2hpbml0Lm1wNBUAAsgBEgAVAhhAZmJfcGVybWFuZW50LzQ0NDI5MDM3RkQ5MTBDQjI4RjM2OTM2NjRBQjJCOUIzX2F1ZGlvX2Rhc2hpbml0Lm1wNBUCAsgBEgAoABgAGwKIB3VzZV9vaWwBMRJwcm9ncmVzc2l2ZV9yZWNpcGUBMRUAACbQt6SI3cOVCBUCKAJDMywXQFWO6XjU_fQYGmRhc2hfaDI2NC1iYXNpYy1nZW4yXzEwODBwEQB1AmWSqQEA&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&_nc_zt=28&oh=00_AQKp8x6TpgEMgdbsufe7OmIT84YLKpqsrYrg7-wWdSxtTw&oe=6AAB2760&bitrate=2180060&tag=dash_h264-basic-gen2_1080p",
"id": "4394633517486098"
},
"is_gaming_video": false,
"is_latency_menu_enabled": false,
"fbls_tier": null,
"is_latency_sensitive_broadcast": false,
"video_player_shaka_performance_logger_should_sample": false,
"autoplay_gating_result": "gatekeeper",
"viewer_autoplay_setting": "default_autoplay",
"can_autoplay": false,
"drm_info": "{\"video_license_uri_map\":{},\"graph_api_video_license_uri\":null,\"fairplay_cert\":null,\"widevine_cert\":\"CsECCAMSEBcFuRfMEgSGiwYzOi93KowYgrSCkgUijgIwggEKAoIBAQCZ7Vs7Mn2rXiTvw7YqlbWYUgrVvMs3UD4GRbgU2Ha430BRBEGtjOOtsRu4jE5yWl5KngeVKR1YWEAjp+GvDjipEnk5MAhhC28VjIeMfiG\\/+\\/7qd+EBnh5XgeikX0YmPRTmDoBYqGB63OBPrIRXsTeo1nzN6zNwXZg6IftO7L1KEMpHSQykfqpdQ4IY3brxyt4zkvE9b\\/tkQv0x4b9AsMYE0cS6TJUgpL+X7r1gkpr87vVbuvVk4tDnbNfFXHOggrmWEguDWe3OJHBwgmgNb2fG2CxKxfMTRJCnTuw3r0svAQxZ6ChD4lgvC2ufXbD8Xm7fZPvTCLRxG88SUAGcn1oJAgMBAAE6FGxpY2Vuc2Uud2lkZXZpbmUuY29tEoADrjRzFLWoNSl\\/JxOI+3u4y1J30kmCPN3R2jC5MzlRHrPMveoEuUS5J8EhNG79verJ1BORfm7BdqEEOEYKUDvBlSubpOTOD8S\\/wgqYCKqvS\\/zRnB3PzfV0zKwo0bQQQWz53ogEMBy9szTK\\/NDUCXhCOmQuVGE98K\\/PlspKkknYVeQrOnA+8XZ\\/apvTbWv4K+drvwy6T95Z0qvMdv62Qke4XEMfvKUiZrYZ\\/DaXlUP8qcu9u\\/r6DhpV51Wjx7zmVflkb1gquc9wqgi5efhn9joLK3\\/bNixbxOzVVdhbyqnFk8ODyFfUnaq3fkC3hR3f0kmYgI41sljnXXjqwMoW9wRzBMINk+3k6P8cbxfmJD4\\/Paj8FwmHDsRfuoI6Jj8M76H3CTsZCZKDJjM3BQQ6Kb2m+bQ0LMjfVDyxoRgvfF\\/\\/M\\/EEkPrKWyU2C3YBXpxaBquO4C8A0ujVmGEEqsxN1HX9lu6c5OMm8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0\"}",
"p2p_settings": null,
"audio_settings": null,
"captions_settings": null,
"broadcast_low_latency_config": null,
"audio_availability": "AVAILABLE",
"muted_segments": [],
"spherical_video_renderer": null,
"preferred_thumbnail": {
"image": {
"uri": "https://scontent-lga3-1.xx.fbcdn.net/v/t15.5256-10/753306317_2059899598252019_7051428696037703086_n.jpg?stp=dst-jpg_tt6&cstp=mx1080x1920&ctp=s960x960&_nc_cat=110&ccb=1-7&_nc_sid=50ce42&_nc_ohc=fFK4qXTnhggQ7kNvwFoTF67&_nc_oc=Adpv6Qoge3bNmPKNIgAXN0RllPhOMDn0nss-rohiTyvwYOtqLQgnEd0BY6Nifk2O4h0&_nc_zt=23&_nc_ht=scontent-lga3-1.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQJEw5D-XUGzIDqfRvd3PYV2VHJbnaJgnBz6niOzlyw-Jw&oe=6AAB2BA6"
},
"id": "2299143310889572"
},
"video_imf_data": null,
"playback_language_on_comet": null,
"warning_screen_renderer": null,
"cix_screen": null
},
"video_owner": {
"__typename": "User",
"id": "100076751910580",
"__isActor": "User",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof",
"displayPicture": {
"uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s40x40&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C"
},
"should_show_reels_follow_back_cta": false,
"subscribe_status": "CANNOT_SUBSCRIBE",
"delegate_page": {
"id": "103285455517243",
"uri_token": "thespeechprof",
"woodhenge_creator_info": {
"viewer_entrypoint_eligibility": {
"is_eligible": true
},
"id": "272843125228141:FubsCreatorInfo",
"viewer_active_subscription_entitlement": null
}
}
},
"fb_shorts_reshare_context": {
"is_reshare": false,
"reshare_creator": {
"__typename": "User",
"__isActor": "User",
"id": "100076751910580",
"name": "SpeechProf",
"enable_reels_tab_deeplink": true,
"is_verified": true,
"url": "https://www.facebook.com/thespeechprof"
}
},
"remix_info": {
"is_remixable": true,
"status": "DISABLED"
},
"video_owner_type": "FACEBOOK_USER",
"soundtrack_info": {
"id": "27856252440654104",
"type": "CUSTOM_AUDIO"
},
"track_title": "SpeechProf · Original audio",
"music_album_art_uri": "https://scontent-lga3-2.xx.fbcdn.net/v/t39.30808-1/326766781_846259129812200_6375271311855814882_n.jpg?stp=cp0_dst-jpg_tt6&cstp=mx540x539&ctp=s64x64&_nc_cat=101&ccb=1-7&_nc_sid=2d3e12&_nc_ohc=h1w1WbToKCgQ7kNvwHgQfMo&_nc_oc=AdqONFEgDcyJWpqq-pZOcHmQbYRuUA60g8gdRhTYljTeq6bGuiQuEBqGHzsevPklY6w&_nc_zt=24&_nc_ht=scontent-lga3-2.xx&_nc_gid=Nu_C1-ECquUeBWruTCocWg&_nc_ss=7b289&oh=00_AQKKGnBsOOARN0WgCapFue7PDpv0Wp3qriUdFQkGVXdSLQ&oe=6AAB216C",
"is_original_audio_on_facebook": true
},
"id": "UzpfSTEwMDA3Njc1MTkxMDU4MDpWSzo0Mzk0NjMzNTE3NDg2MDk4",
"sponsored_data": null,
"brand_safety_data": null,
"post_id": "1067862332448821",
"creation_time": 1784756186,
"unpublished_content_type": "PUBLISHED",
"scheduled_publish_time": null,
"sponsor_relationship": 0,
"branded_content_post_info": null,
"creator_collaboration": null,
"media_attribution_elements": [],
"attachments": [
{
"media": {
"__typename": "Video",
"is_made_with_meta_ai_app": false,
"__isNode": "Video",
"id": "4394633517486098",
"video_delivery_response": {
"transcript_urls": [],
"id": "4394633517486098"
}
},
"call_to_action_renderer": null
}
],
"can_viewer_delete": false,
"can_viewer_edit": false,
"can_viewer_cancel_collaboration_invite": false,
"can_viewer_remove_collaborator": false,
"can_viewer_see_collaboration_invite": false,
"legal_reporting_cta_type": null,
"legal_reporting_uri": null,
"to": null,
"video": {
"id": "4394633517486098",
"owner": {
"__typename": "User",
"id": "100076751910580"
}
},
"post_collaboration": null,
"can_viewer_remove_self_as_collaborator": false,
"if_viewer_can_see_stars_toggle_menu_option": null,
"transparency_ad_info": {
"menu_label": "Ad info",
"should_display_ad_info": false
},
"debug_info": null,
"inform_treatment_for_community_notes": null,
"message": {
"text": "Love you Ryan & Craig and sorry for any future confusion 🤣",
"ranges": [
{
"offset": 9,
"length": 12,
"entity": {
"__typename": "User",
"__isEntity": "User",
"mobileUrl": "https://m.facebook.com/StorytimewithRyanandCraig/",
"url": "https://www.facebook.com/StorytimewithRyanandCraig",
"__isNode": "User",
"id": "100064615544947"
}
}
]
},
"translated_message_for_viewer": null,
"contextualElementShortFormContext": {
"video_label": null
},
"__isNode": "Story"
},
"cursor": "1",
"id": "UzpfSTEwMDA3Njc1MTkxMDU4MDpWSzo0Mzk0NjMzNTE3NDg2MDk4"
}
],
"page_info": {
"end_cursor": "5",
"has_next_page": false
}
}
},
"qp_viewer": {
"eligible_promotions": {
"nodes": []
}
}
}
},
"sequence_number": 0
}
}
]
],
[
"RequireDeferredReference",
"unblock",
[],
[
[
"useVideoPlayerShakaPerformanceLoggerRelayImpl_init$normalization.graphql",
"VideoPlayerShakaPerformanceLogger",
"useVideoPlayerShakaPerformanceLoggerBuilder_init$normalization.graphql",
"VideoPlayerShakaPerformanceLoggerBuilder"
],
"sd"
]
],
[
"RequireDeferredReference",
"unblock",
[],
[
[
"useVideoPlayerShakaPerformanceLoggerRelayImpl_init$normalization.graphql",
"VideoPlayerShakaPerformanceLogger",
"useVideoPlayerShakaPerformanceLoggerBuilder_init$normalization.graphql",
"VideoPlayerShakaPerformanceLoggerBuilder"
],
"css"
]
],
[
"CometResourceScheduler",
"registerHighPriHashes",
null,
[
[
"yFGOD2w",
"eZENHpr",
"c7oHeza",
"pvDSJnA",
"tWsD7LM",
"SG1FRQy"
]
]
]
],
"phd2_indexes": ":401,400,402,403"
}
},
{
"__bbox": null
},
{
"__bbox": null
}
]
]
]
}
]
File diff suppressed because it is too large Load Diff
-44
View File
@@ -1,44 +0,0 @@
{
"__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"
}
]
}
-19
View File
@@ -1,7 +1,6 @@
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', () => {
@@ -27,24 +26,6 @@ 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');
-89
View File
@@ -1,16 +1,9 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { escapeHtml } from '../src/render/html.ts';
import { renderMarkdown } from '../src/render/markdown.ts';
const md = (text: string): string => String(renderMarkdown(text));
/** Stands in for the real one, which proxies. Escapes the way that one does:
* placing the image is the renderer's job, and so is making it safe. */
const img = (url: string, alt: string): string =>
`<img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}">`;
const mdi = (text: string): string => String(renderMarkdown(text, img));
test('markup a commenter typed is text, not markup', () => {
const out = md('<script>alert(1)</script> & "quoted"');
assert.ok(!out.includes('<script>'));
@@ -71,85 +64,3 @@ test('a single newline inside a paragraph is a line break, a blank line is a new
assert.equal(md('one\ntwo'), '<p>one<br>two</p>');
assert.equal(md('one\n\ntwo'), '<p>one</p><p>two</p>');
});
test('an image is a picture when there is something to place it with', () => {
assert.equal(mdi('![a cat](https://i.redd.it/x.jpg)'),
'<p><img src="https://i.redd.it/x.jpg" alt="a cat"></p>');
});
test('an image degrades to a link when there is not', () => {
const out = md('![a cat](https://i.redd.it/x.jpg)');
assert.ok(out.includes('<a href="https://i.redd.it/x.jpg"'));
assert.ok(!out.includes('<img'));
});
test("an image's `!` is not left behind as text", () => {
// The link rule would otherwise match from the `[` and strand the bang.
assert.ok(!mdi('![](https://i.redd.it/x.png)').includes('!'));
});
test('a pasted image address becomes the picture, not a link to it', () => {
// Which is how most images in a Reddit comment arrive.
const out = mdi('look\n\nhttps://preview.redd.it/abc.jpeg?width=1274&s=deadbeef');
assert.ok(out.includes('<img src="https://preview.redd.it/abc.jpeg?width=1274&amp;s=deadbeef"'));
assert.ok(!out.includes('<a href'));
});
test('a link that is not an image is still a link', () => {
const out = mdi('see https://example.com/article');
assert.ok(out.includes('<a href="https://example.com/article"'));
assert.ok(!out.includes('<img'));
});
test('a sentence after a pasted image keeps its punctuation out of the address', () => {
const out = mdi('here https://i.redd.it/x.jpg.');
assert.ok(out.includes('src="https://i.redd.it/x.jpg"'), out);
assert.ok(out.endsWith('.</p>'), out);
});
test('only http and https become pictures', () => {
const out = mdi('![x](javascript:alert(1))');
assert.ok(!out.includes('<img'));
assert.ok(out.includes('![x]'));
});
test("an image's alt text is escaped like anything else a stranger wrote", () => {
const out = mdi('![" onerror=alert(1)](https://i.redd.it/x.jpg)');
assert.ok(!out.includes('onerror=alert(1)>'), out);
assert.ok(out.includes('&quot;'));
});
test('images inside a quote are still placed', () => {
assert.match(mdi('> ![](https://i.redd.it/x.gif)'), /<blockquote><p><img/);
});
test('an image address typed without a scheme is still the picture', () => {
// Which is how people type them: no https, straight from the address bar.
const out = mdi('preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&s=b27e');
assert.ok(out.includes('<img src="https://preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&amp;s=b27e"'), out);
});
test('a schemeless image address mid-sentence keeps the sentence', () => {
const out = mdi('look at i.redd.it/x.png nice one');
assert.ok(out.startsWith('<p>look at <img'), out);
assert.ok(out.endsWith(' nice one</p>'), out);
});
test('prose full of dots and slashes is not mistaken for an address', () => {
// The reason this rule insists on a host, a path and an image extension.
for (const text of [
'the file is at src/render/post.ts',
'see node_modules/foo/bar.js',
'a path like ./images/cat.jpg',
'C:/Users/x/cat.png',
'email [email protected]/nope.jpg',
'version 1.2.3/4.png',
]) {
assert.ok(!mdi(text).includes('<img'), `treated as an image: ${text}`);
}
});
test('a schemeless address that is not an image is left alone', () => {
// Guessing a scheme is worth it for a picture and not for prose.
assert.equal(mdi('example.com/article'), '<p>example.com/article</p>');
});
+1 -25
View File
@@ -1,11 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
findCaption,
findMetaMedia,
mediaFromMetaNode,
mediaFromOpenGraph,
} from '../src/platforms/meta-media.ts';
import { findCaption, findMetaMedia, mediaFromMetaNode } from '../src/platforms/meta-media.ts';
import { fixture } from './helpers.ts';
const REFERER = 'https://www.threads.com/';
@@ -73,22 +68,3 @@ test('the caption is found the same way, and an empty one is not a caption', ()
assert.equal(findCaption({ x: { caption: { text: '' } } }), undefined);
assert.equal(findCaption({ x: { caption: null } }), undefined);
});
test('the link preview stands in for the post when there is nothing better', () => {
const image = mediaFromOpenGraph({ 'og:image': 'https://image.example/a.jpg' }, REFERER);
assert.deepEqual(image.map((item) => [item.kind, item.url]), [['image', 'https://image.example/a.jpg']]);
assert.equal(image[0]?.fetchHeaders?.['Referer'], REFERER);
});
test('a preview naming a video is a video, and its image is the poster', () => {
const media = mediaFromOpenGraph(
{ 'og:video': 'https://video.example/a.mp4', 'og:image': 'https://image.example/a.jpg' },
REFERER,
);
assert.equal(media[0]?.kind, 'video');
assert.equal(media[0]?.kind === 'video' ? media[0].poster?.url : undefined, 'https://image.example/a.jpg');
});
test('a page with no preview at all yields no media rather than an empty asset', () => {
assert.deepEqual(mediaFromOpenGraph({ 'og:title': 'just a title' }, REFERER), []);
});
+1 -134
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { commentsFrom, mediaFromLink, resolveInlineImages, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
import { commentsFrom, mediaFromLink, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
import { reddit } from '../src/platforms/reddit.ts';
import { originalUrlFor } from '../src/platforms/index.ts';
import { fixture } from './helpers.ts';
@@ -56,7 +56,6 @@ test('a video with sound is the HLS playlist, because the MP4 has no audio track
duration: 42,
},
},
preview: { images: [{ source: { url: 'https://external-preview.redd.it/still.png', width: 1920, height: 1080 } }] },
});
assert.deepEqual(media, [
@@ -68,9 +67,6 @@ test('a video with sound is the HLS playlist, because the MP4 has no audio track
width: 1920,
height: 1080,
durationSec: 42,
// The still is the video's, not the MP4's. Dropping it here left every
// post with sound showing an empty box where a silent one showed a frame.
poster: { url: 'https://external-preview.redd.it/still.png' },
},
]);
});
@@ -172,132 +168,3 @@ test('the page fallback rebuilds nesting from the depth on each comment', () =>
test('a comment the page gave no text for is dropped rather than shown empty', () => {
assert.deepEqual(treeFromDepths([{ depth: 0, author: 'a', score: 1, created: '', text: '' }]), []);
});
// Real shapes, captured from comments carrying each kind.
const GIPHY = {
'giphy|Ve7wX45gaOFmw8eeEM': {
status: 'valid',
e: 'AnimatedImage',
m: 'image/gif',
s: {
y: 200,
x: 304,
gif: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9',
mp4: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&format=mp4&s=a389',
},
},
};
const UPLOAD = {
jo8gf0ca92zd1: {
status: 'valid',
e: 'Image',
m: 'image/jpeg',
s: { y: 1270, x: 1274, u: 'https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226' },
},
};
test('a giphy comment points at the gif rather than at a token', () => {
// `![gif](giphy|ID)` is not an address, and renders as nothing at all until
// it is looked up in the comment's own media_metadata.
assert.equal(
resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY),
'![gif](https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9)',
);
});
test('the animated form takes the gif, which plays on its own', () => {
const out = resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY);
assert.ok(out.includes('.gif?'), out);
assert.ok(!out.includes('format=mp4'), 'an mp4 would need a player to move');
});
test('an uploaded image resolves through the same lookup', () => {
// Giphy, emotes and uploads are all a token that is a key in the same map,
// so none of them needs naming.
assert.equal(
resolveInlineImages('![img](jo8gf0ca92zd1)', UPLOAD),
'![img](https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226)',
);
});
test('a target that is already an address is left alone', () => {
const already = '![a](https://i.redd.it/x.jpg)';
assert.equal(resolveInlineImages(already, UPLOAD), already);
assert.equal(resolveInlineImages(already, undefined), already);
});
test('a token with no entry, or a broken one, is not invented', () => {
// An upload id and an emote id mean nothing off Reddit, so with no entry to
// look them up in there is nothing to point them at.
assert.equal(resolveInlineImages('![img](missing)', GIPHY), '![img](missing)');
assert.equal(
resolveInlineImages('![img](gone)', { gone: { status: 'failed', e: 'Image' } }),
'![img](gone)',
);
});
test('a giphy token resolves even when the comment carried no metadata', () => {
// Reddit ships plenty of these with no `media_metadata` at all. The id in
// the token is Giphy's own, so it does not need Reddit to be readable.
assert.equal(
resolveInlineImages('![gif](giphy|zUW23b6FmzB5e)', undefined),
'![gif](https://i.giphy.com/media/zUW23b6FmzB5e/giphy.gif)',
);
assert.equal(
resolveInlineImages('![gif](giphy|missing)', GIPHY),
'![gif](https://i.giphy.com/media/missing/giphy.gif)',
);
});
test('the metadata still wins where there is any, being what Reddit will serve', () => {
assert.match(resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY), /redd\.it/);
});
test('a variant name after the id is dropped, not all of them being served', () => {
assert.equal(
resolveInlineImages('![gif](giphy|zUW23b6FmzB5e|downsized)', undefined),
'![gif](https://i.giphy.com/media/zUW23b6FmzB5e/giphy.gif)',
);
});
test('only a giphy token is guessed at, and only a well-formed one', () => {
for (const token of ['emote|t5_2th52|4358', 'giphy|', 'giphy|../evil', 'giphy|a|b|c']) {
assert.equal(resolveInlineImages(`![x](${token})`, undefined), `![x](${token})`);
}
});
test('a real comment carrying a giphy token gets the gif', () => {
// Captured from the post itself: the comment has the token and no
// `media_metadata`, which is the shape that used to show the token instead.
const post = toPost(fixture('reddit/video.json'), URL_);
const all: string[] = [];
const walk = (list: typeof post.comments) => {
for (const comment of list ?? []) {
if (comment.text) all.push(comment.text);
walk(comment.replies);
}
};
walk(post.comments);
const gif = all.find((text) => text.includes('giphy'));
assert.ok(gif, 'the fixture should still carry a giphy comment');
assert.match(gif, /!\[gif\]\(https:\/\/i\.giphy\.com\/media\/QfzMP70zmNQiDf5sGP\/giphy\.gif\)/);
});
test('inline images survive the walk into the comment tree', () => {
const { comments } = commentsFrom({
data: {
children: [{
kind: 't1',
data: {
author: 'a',
body: 'ha ![gif](giphy|Ve7wX45gaOFmw8eeEM)',
media_metadata: GIPHY,
replies: '',
},
}],
},
});
assert.match(comments[0]?.text ?? '', /external-preview\.redd\.it/);
});
+1 -44
View File
@@ -90,27 +90,6 @@ test('a video gets native controls and a source, not an iframe', () => {
assert.ok(!page.includes('<iframe'));
});
test('a video with no poster still carries its shape, so the box is right before play', () => {
// WebKit sizes a video from its natural size, which without data or a
// poster is 300x150 — a portrait video sat in a landscape box until you
// pressed play. The poster is where the shape comes from until then.
const page = renderPost(withMedia([
{ kind: 'video', url: 'https://video.example/p.m3u8', hls: true, direct: true, width: 720, height: 1280 },
]));
assert.ok(page.includes('aspect-ratio: 720 / 1280;'));
assert.match(page, /poster="data:image\/svg\+xml,[^"]*width%3D%22720%22[^"]*height%3D%221280%22/);
});
test('a real poster is left in place, and an unmeasured video gets none', () => {
const withPoster = renderPost(withMedia([
{ kind: 'video', url: 'https://cdn/v.mp4', width: 720, height: 1280, poster: { url: 'https://cdn/p.jpg' } },
]));
assert.match(withPoster, /poster="\/m\//);
assert.ok(!withPoster.includes('data:image/svg'));
assert.ok(!renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }])).includes('poster='));
});
test('the copy button carries the clean original URL, and so does the page text', () => {
const page = renderPost(post());
assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"'));
@@ -126,12 +105,8 @@ test('a failure still hands the link back', () => {
detail: 'TikTok showed a verification puzzle instead of the post.',
});
assert.ok(page.includes('data-url="https://www.tiktok.com/@a/video/1"'));
assert.ok(page.includes('<code>https://www.tiktok.com/@a/video/1</code>'));
assert.ok(page.includes('Open on TikTok'));
assert.ok(page.includes('verification puzzle'));
// Following the original link in the browser the rewrite rules are
// installed in only comes back here, so it stays hidden until the script
// has a browser to hand it to.
assert.ok(/<a class="original__open"[^>]* hidden>Open on TikTok<\/a>/.test(page));
});
function redditPost(overrides: Partial<Post> = {}): Post {
@@ -262,21 +237,3 @@ test('markup in a quoted post is escaped like any other stranger\'s text', () =>
assert.ok(!html_.includes('<script>alert(1)</script>'));
assert.ok(!html_.includes('<img src=x'));
});
test('a picture in a comment is proxied, and its alt text cannot break out', () => {
const page = renderPost(redditPost({
comments: [{
author: 'u/a',
text: '![" onerror=alert(1) x="](https://preview.redd.it/x.jpeg?s=abc)\n\nhttps://i.redd.it/y.png',
replies: [],
}],
}));
assert.ok(!page.includes('https://preview.redd.it/x.jpeg'), 'upstream URLs must not reach the page');
assert.ok(!page.includes('https://i.redd.it/y.png'), 'a pasted address is proxied too');
assert.equal((page.match(/<img class="c__img" src="\/m\//g) ?? []).length, 2);
// The payload survives as text inside the attribute, which is the point:
// its quotes are neutered, so it cannot close `alt="` and become markup.
assert.ok(page.includes('alt="&quot; onerror=alert(1) x=&quot;"'), 'alt text must be escaped');
assert.ok(!/alt="" onerror/.test(page), 'the attribute must not be closable');
});
-4
View File
@@ -17,10 +17,6 @@
// Source imports carry the real `.ts` specifier so `node --experimental-
// strip-types` can run the tree directly for dev, tests and the resolve
// CLI. tsc rewrites them to `.js` on the way into dist.
// The client-side scheme table is plain JS, because the browser loads it
// as-is out of `public`. Its types are inferred so the test can check it.
"allowJs": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,