Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f60367e2f
|
||
|
|
ab23c2b9fe
|
||
|
|
fa07b43c1c
|
||
|
|
635bc9d0a4
|
||
|
|
4e45456097 | ||
|
|
05455a86da | ||
|
|
4c78ce8a27
|
||
|
|
d9fa2f5768 | ||
|
|
dfabfc18d6 | ||
|
|
3afdf6ffe2 | ||
|
|
5b4378a838
|
||
|
|
4669fe0b6a
|
||
|
|
ebfcc9ebf2 | ||
|
|
8fc7a83f8e | ||
|
|
91d4c2da50
|
||
|
|
899b6e38d8
|
||
|
|
abf8ec317c
|
||
|
|
3ff7375320
|
||
|
|
43dc10e1dc
|
||
|
|
b94c43a10c
|
@@ -157,19 +157,26 @@ jobs:
|
|||||||
# it is a candidate for, so there is nothing yet to move past.
|
# it is a candidate for, so there is nothing yet to move past.
|
||||||
if: github.ref_type == 'tag' && !contains(github.ref_name, '-')
|
if: github.ref_type == 'tag' && !contains(github.ref_name, '-')
|
||||||
runs-on: ubuntu-latest
|
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:
|
container:
|
||||||
image: node:22
|
image: node:22
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
steps:
|
steps:
|
||||||
# The tag names a commit in main's history, but the bump belongs on the
|
# 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.
|
# branch, so this checks out main rather than the tag. The full history
|
||||||
|
# because a shallow clone cannot reliably push a branch back.
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
# The task token can push only if the instance allows Actions to
|
fetch-depth: 0
|
||||||
# write to the repository. Where it does not, set VERSION_BUMP_TOKEN
|
token: ${{ secrets.GITHUB_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
|
- name: Work out the next patch version
|
||||||
id: next
|
id: next
|
||||||
@@ -211,30 +218,59 @@ jobs:
|
|||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "package.json ${current} -> ${NEXT}"
|
echo "package.json ${current} -> ${NEXT}"
|
||||||
|
|
||||||
- name: Commit it to main
|
# 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
|
||||||
if: steps.bump.outputs.changed == 'true'
|
if: steps.bump.outputs.changed == 'true'
|
||||||
env:
|
env:
|
||||||
NEXT: ${{ steps.next.outputs.next }}
|
NEXT: ${{ steps.next.outputs.next }}
|
||||||
|
RELEASED: ${{ github.ref_name }}
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
branch="release/bump-${NEXT}"
|
||||||
|
|
||||||
# A name that is not a person, and a reserved address that can never
|
# 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.
|
# resolve to one. Nothing here names the instance it runs on.
|
||||||
git config user.name 'Release bot'
|
git config user.name 'Release bot'
|
||||||
git config user.email '[email protected]'
|
git config user.email '[email protected]'
|
||||||
|
|
||||||
|
git checkout -b "${branch}"
|
||||||
git add package.json package-lock.json
|
git add package.json package-lock.json
|
||||||
# `[skip ci]` because this commit is a number and nothing else:
|
git commit -m "Set the working version to ${NEXT}"
|
||||||
# without it the push to main starts another build of the very image
|
git push origin "${branch}"
|
||||||
# that was just published.
|
|
||||||
git commit -m "Set the working version to ${NEXT} [skip ci]"
|
|
||||||
|
|
||||||
if ! git push origin HEAD:main; then
|
# node rather than jq to build the request body: jq is not in this
|
||||||
echo >&2
|
# image, and node is the one thing that certainly is.
|
||||||
echo "Could not push the version bump to main. Either the Actions" >&2
|
payload="$(BRANCH="${branch}" node -e 'process.stdout.write(JSON.stringify({
|
||||||
echo "token has no write access to this repository, or main is" >&2
|
head: process.env.BRANCH,
|
||||||
echo "protected against direct pushes. Set VERSION_BUMP_TOKEN to a" >&2
|
base: "main",
|
||||||
echo "token that may push to main, or allow that token past the" >&2
|
title: `Set the working version to ${process.env.NEXT}`,
|
||||||
echo "branch protection." >&2
|
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
|
exit 1
|
||||||
fi
|
;;
|
||||||
|
esac
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# antisocial — working notes
|
# antisocial — working notes
|
||||||
|
|
||||||
A self-hosted page that shows a social post without the app. StopTheMadness rewrites
|
A self-hosted page that shows a social post without the app. StopTheMadness rewrites
|
||||||
links to X, Threads, Instagram, TikTok, Bluesky and Reddit into
|
links to X, Threads, Instagram, Facebook, TikTok, Bluesky and Reddit into
|
||||||
`/<prefix>/<original path>`; this resolves the post by driving a real headless Chromium
|
`/<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.
|
and renders the media, the text, a platform badge and a copy-the-original button.
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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.**
|
**3. Look at what the page actually served.**
|
||||||
@@ -89,6 +90,9 @@ Things worth knowing before editing:
|
|||||||
- **Extraction is layered**, most structured first: the platform's own API response
|
- **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
|
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.
|
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,
|
- **`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
|
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.
|
payload looks present but parses to nothing, suspect an extra encoding level.
|
||||||
@@ -96,6 +100,15 @@ Things worth knowing before editing:
|
|||||||
`src/media/registry.ts` and served from `/m/<id>` with the `Referer`/`Cookie` the CDN
|
`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.
|
demands. `Range` is forwarded — without it the native video scrubber cannot seek.
|
||||||
The exception is HLS (`direct: true`), because proxying would mean rewriting playlists.
|
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
|
- **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
|
original URL and the copy button. A broken adapter must still leave the link one tap
|
||||||
away.
|
away.
|
||||||
@@ -144,19 +157,54 @@ Things worth knowing before editing:
|
|||||||
Video: `fallback_url` is the *video track alone* whenever `has_audio` is true, so a
|
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
|
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
|
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`. A gallery's pictures are in
|
timeline thumbnails, and the still is in `preview.images` — which belongs to both
|
||||||
`media_metadata`, keyed and unordered; their order is only in `gallery_data`. Comment
|
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
|
||||||
bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put
|
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
|
back the constructs we chose to support, never `body_html`. An image in a comment is
|
||||||
written as a token rather than an address — ``,
|
written as a token rather than an address — ``,
|
||||||
``, `` — and in every case the token is
|
``, `` — and the token is usually a key
|
||||||
a key in that same comment's own `media_metadata`, so `resolveInlineImages` is one
|
in that same comment's own `media_metadata`, so `resolveInlineImages` is one lookup
|
||||||
lookup rather than three special cases. A bare `preview.redd.it` address pasted into a
|
rather than three special cases. Usually: plenty of comments carry a Giphy token and
|
||||||
comment is in there too, keyed by the id inside the URL. Prefer `s.gif` over `s.mp4`
|
no `media_metadata` at all, and Giphy is the one of the three whose id means something
|
||||||
for an animated one: a GIF moves in an `<img>` and an MP4 needs a player. An address
|
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 —
|
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
|
the rule wants a host, a path *and* that extension, because comments are full of
|
||||||
dotted, slashed prose that must not turn into links.
|
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.`.
|
||||||
- **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its
|
- **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
|
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
|
candidates in it. The page ships the linked post, the author's follow-ups, other
|
||||||
@@ -183,6 +231,10 @@ 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
|
- **No ESLint** — `typescript-eslint` does not support TS 7 yet. `npm run typecheck` is
|
||||||
the lint step.
|
the lint step.
|
||||||
- Tests are `node:test` against captured fixtures. No network in the test suite.
|
- 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.
|
- 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.
|
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
|
- Post text comes from strangers: everything goes through the `html` tagged template or
|
||||||
@@ -210,16 +262,22 @@ 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
|
`IMAGE_NAME` or the repository name, and credentials from `REGISTRY_USER` and the
|
||||||
`REGISTRY_TOKEN` secret. Nothing about any particular deployment is committed here.
|
`REGISTRY_TOKEN` secret. Nothing about any particular deployment is committed here.
|
||||||
|
|
||||||
A release also moves `package.json` on to the next patch version, committed to main by
|
A release also moves `package.json` on to the next patch version, opened as a pull
|
||||||
the `bump` job — so the number in the tree is never one that has already shipped and
|
request by the `bump` job — so the number in the tree is never one that has already
|
||||||
been made immutable. It lives in `publish.yml` rather than a workflow of its own so it
|
shipped and been made immutable. It lives in `publish.yml` rather than a workflow of its
|
||||||
can say `needs: build`: a version that failed to publish has not been released, and
|
own so it can say `needs: build`: a version that failed to publish has not been
|
||||||
bumping past it would claim otherwise. Prereleases are skipped, being candidates for a
|
released, and bumping past it would claim otherwise. Prereleases are skipped, being
|
||||||
version that has not shipped. The bump goes through `npm version` rather than an edit in
|
candidates for a version that has not shipped. The bump goes through `npm version`
|
||||||
place, because the version is in the lockfile too, in more than one place, and the two
|
rather than an edit in place, because the version is in the lockfile too, in more than
|
||||||
have to agree. The commit carries `[skip ci]`, or pushing it would rebuild the image
|
one place, and the two have to agree.
|
||||||
that was just published. Pushing to main needs a token with write access —
|
|
||||||
`VERSION_BUMP_TOKEN` overrides the task token where that one cannot.
|
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.
|
||||||
|
|
||||||
Two things any deployment has to get right, both learned the hard way:
|
Two things any deployment has to get right, both learned the hard way:
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
Reads social posts back to you without the app.
|
Reads social posts back to you without the app.
|
||||||
|
|
||||||
Links to X, Threads, Instagram, TikTok, Bluesky and Reddit get shared constantly, and
|
Links to X, Threads, Instagram, Facebook, TikTok, Bluesky and Reddit get shared
|
||||||
opening one means an app interstitial, a login wall, a feed you didn't ask for, and a
|
constantly, and opening one means an app interstitial, a login wall, a feed you didn't
|
||||||
pile of tracking. antisocial is the other half of a StopTheMadness rewrite rule: the link gets
|
ask for, and a pile of tracking. antisocial is the other half of a StopTheMadness rewrite
|
||||||
redirected here, and you get the post — the media and the words — plus a badge saying
|
rule: the link gets redirected here, and you get the post — the media and the words —
|
||||||
where it came from and a button to copy the original URL if you do want to go there.
|
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** —
|
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
|
anything that can reach it can drive a browser through it, so put it somewhere only you
|
||||||
@@ -37,6 +38,10 @@ https://antisocial.example.com/threads/$1
|
|||||||
/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/
|
/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/
|
||||||
https://antisocial.example.com/ig/$1
|
https://antisocial.example.com/ig/$1
|
||||||
|
|
||||||
|
# Facebook
|
||||||
|
/^https:\/\/(?:www\.|m\.|web\.)?facebook\.com\/(.*)$/
|
||||||
|
https://antisocial.example.com/fb/$1
|
||||||
|
|
||||||
# TikTok
|
# TikTok
|
||||||
/^https:\/\/(?:www\.|vm\.|vt\.)?tiktok\.com\/(.*)$/
|
/^https:\/\/(?:www\.|vm\.|vt\.)?tiktok\.com\/(.*)$/
|
||||||
https://antisocial.example.com/tiktok/$1
|
https://antisocial.example.com/tiktok/$1
|
||||||
@@ -68,11 +73,42 @@ 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>`
|
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
|
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
|
post it points at, and that permalink — not the opaque share code — is what the copy
|
||||||
button hands back.
|
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.
|
||||||
|
|
||||||
`/` serves these rules with the live hostname already filled in, if you'd rather copy
|
`/` serves these rules with the live hostname already filled in, if you'd rather copy
|
||||||
them from there.
|
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.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
Every request drives a real Chromium page load. One code path, and it survives markup
|
Every request drives a real Chromium page load. One code path, and it survives markup
|
||||||
@@ -93,6 +129,7 @@ Each adapter layers its extraction, most structured first:
|
|||||||
| Bluesky | the public AT Protocol API | `getPostThread`; falls back to the post page |
|
| 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 |
|
| X | `platform.twitter.com` embed | the `cdn.syndication.twimg.com/tweet-result` response |
|
||||||
| Instagram | `/embed/captioned/` | `shortcode_media`, then the rendered `<video>`/`<img>` |
|
| 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__` |
|
| TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
|
||||||
| Threads | the post page | the Relay payloads in `<script type="application/json">` |
|
| 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 |
|
| Reddit | the post's own `.json` | the post and the first page of comments; falls back to the page |
|
||||||
@@ -142,6 +179,10 @@ 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
|
rewriting the manifest and every segment, so it is linked directly. Safari plays HLS
|
||||||
natively; other browsers show a note.
|
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
|
Resolved posts are cached in memory for an hour, so a reload or a back button doesn't
|
||||||
drive the browser again.
|
drive the browser again.
|
||||||
|
|
||||||
@@ -173,6 +214,11 @@ Known rough edges:
|
|||||||
- **Instagram is the least reliable.** It only ships the structured payload some of the
|
- **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
|
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.
|
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
|
- **TikTok sometimes answers with a slider puzzle** instead of the post. You get handed
|
||||||
the puzzle rather than an error — see below.
|
the puzzle rather than an error — see below.
|
||||||
- **Reddit refuses `.json` to a browser it has never seen.** The first request of a cold
|
- **Reddit refuses `.json` to a browser it has never seen.** The first request of a cold
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "antisocial",
|
"name": "antisocial",
|
||||||
"version": "1.2.1",
|
"version": "1.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "antisocial",
|
"name": "antisocial",
|
||||||
"version": "1.2.1",
|
"version": "1.2.5",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "10.1.3",
|
"@fastify/static": "10.1.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "antisocial",
|
"name": "antisocial",
|
||||||
"version": "1.2.1",
|
"version": "1.2.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Reads social posts back to you without the app.",
|
"description": "Reads social posts back to you without the app.",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
@@ -242,6 +242,30 @@ 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 td { padding: 8px 8px 8px 0; border-top: 1px solid var(--line); vertical-align: middle; }
|
||||||
.index__table code { overflow-wrap: anywhere; }
|
.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 ---------- */
|
/* ---------- verification puzzle ---------- */
|
||||||
|
|
||||||
.challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); }
|
.challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); }
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// Progressive enhancement only. Without this file the page still shows the
|
// Progressive enhancement only. Without this file the page still shows the
|
||||||
// media in a swipeable rail and the original URL as selectable text.
|
// 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 VIEW_KEY = 'antisocial:view';
|
||||||
|
const BROWSER_KEY = 'antisocial:browser';
|
||||||
|
|
||||||
function setupCopy() {
|
function setupCopy() {
|
||||||
for (const button of document.querySelectorAll('.copy')) {
|
for (const button of document.querySelectorAll('.copy')) {
|
||||||
@@ -37,6 +40,62 @@ 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
|
// 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.
|
// the post it quotes -- and wiring only the first leaves the rest inert.
|
||||||
function setupMedia(media) {
|
function setupMedia(media) {
|
||||||
@@ -96,6 +155,19 @@ 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
|
// 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
|
// file missing. All this adds is doing the whole page at once, which is why
|
||||||
// the button ships hidden and is only revealed here.
|
// the button ships hidden and is only revealed here.
|
||||||
@@ -127,7 +199,10 @@ function setupComments() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupCopy();
|
setupCopy();
|
||||||
|
applyBrowser(storedBrowser());
|
||||||
|
setupBrowserPicker();
|
||||||
for (const media of document.querySelectorAll('.media')) setupMedia(media);
|
for (const media of document.querySelectorAll('.media')) setupMedia(media);
|
||||||
|
setupAudioSession();
|
||||||
setupComments();
|
setupComments();
|
||||||
|
|
||||||
// --- Verification puzzles -------------------------------------------------
|
// --- Verification puzzles -------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
@@ -6,8 +6,11 @@ import { instagram } from './instagram.ts';
|
|||||||
import { tiktok } from './tiktok.ts';
|
import { tiktok } from './tiktok.ts';
|
||||||
import { bluesky } from './bluesky.ts';
|
import { bluesky } from './bluesky.ts';
|
||||||
import { reddit } from './reddit.ts';
|
import { reddit } from './reddit.ts';
|
||||||
|
import { facebook } from './facebook.ts';
|
||||||
|
|
||||||
export const platforms: readonly PlatformSpec[] = [x, threads, instagram, tiktok, bluesky, reddit];
|
export const platforms: readonly PlatformSpec[] = [
|
||||||
|
x, threads, instagram, facebook, tiktok, bluesky, reddit,
|
||||||
|
];
|
||||||
|
|
||||||
const byPrefix = new Map(platforms.map((p) => [p.prefix, p]));
|
const byPrefix = new Map(platforms.map((p) => [p.prefix, p]));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { ResolveError, oneSegment, type Media, type Post } from '../types.ts';
|
import { ResolveError, oneSegment, type Media, type Post } from '../types.ts';
|
||||||
import { readOpenGraph } from '../browser/capture.ts';
|
import { readOpenGraph } from '../browser/capture.ts';
|
||||||
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts';
|
import {
|
||||||
|
findCaption,
|
||||||
|
findMetaMedia,
|
||||||
|
mediaFromMetaNode,
|
||||||
|
mediaFromOpenGraph,
|
||||||
|
metaHeaders,
|
||||||
|
} from './meta-media.ts';
|
||||||
import { extractJsonObjectAfterKey } from './scan.ts';
|
import { extractJsonObjectAfterKey } from './scan.ts';
|
||||||
import { hostMatcher } from './url.ts';
|
import { hostMatcher } from './url.ts';
|
||||||
import type { PlatformSpec, ResolveContext } from './types.ts';
|
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||||
@@ -202,22 +208,8 @@ function fromPayloads(texts: string[]): Scraped | undefined {
|
|||||||
/** Absolute floor: whatever the page offers a link preview. */
|
/** Absolute floor: whatever the page offers a link preview. */
|
||||||
async function fromOpenGraph(ctx: ResolveContext): Promise<Scraped> {
|
async function fromOpenGraph(ctx: ResolveContext): Promise<Scraped> {
|
||||||
const og = await readOpenGraph(ctx.page);
|
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 {
|
return {
|
||||||
media,
|
media: mediaFromOpenGraph(og, REFERER),
|
||||||
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Media } from '../types.ts';
|
import type { Media } from '../types.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instagram and Threads are the same product underneath and serve the same
|
* Instagram, Threads and Facebook are the same product underneath. The first
|
||||||
* media schema, so both adapters normalize through here.
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Candidate = { url?: string; width?: number; height?: number };
|
type Candidate = { url?: string; width?: number; height?: number };
|
||||||
@@ -66,6 +68,27 @@ export function mediaFromMetaNode(node: MetaMediaNode, referer: string): Media[]
|
|||||||
return [];
|
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 {
|
function nonEmptyArray(value: unknown): boolean {
|
||||||
return Array.isArray(value) && value.length > 0;
|
return Array.isArray(value) && value.length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-17
@@ -104,6 +104,10 @@ function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[
|
|||||||
const common = {
|
const common = {
|
||||||
...sized(video.width, video.height),
|
...sized(video.width, video.height),
|
||||||
...(video.duration ? { durationSec: video.duration } : {}),
|
...(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) {
|
if (video.has_audio !== false && video.hls_url) {
|
||||||
@@ -114,12 +118,7 @@ function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[
|
|||||||
if (url === video.hls_url) {
|
if (url === video.hls_url) {
|
||||||
return [{ kind: 'video', url, hls: true, direct: true, ...common }];
|
return [{ kind: 'video', url, hls: true, direct: true, ...common }];
|
||||||
}
|
}
|
||||||
return [{
|
return [{ kind: 'video', url, ...common }];
|
||||||
kind: 'video',
|
|
||||||
url,
|
|
||||||
...(poster ? { poster: { url: poster } } : {}),
|
|
||||||
...common,
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One entry of a gallery post. Reddit keeps the pictures somewhere other
|
/** One entry of a gallery post. Reddit keeps the pictures somewhere other
|
||||||
@@ -192,14 +191,30 @@ function bodyOf(link: Link): string | undefined {
|
|||||||
/** The whole of ``, with the target captured. */
|
/** The whole of ``, with the target captured. */
|
||||||
const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g;
|
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.
|
* Point a comment's inline images at something fetchable.
|
||||||
*
|
*
|
||||||
* Reddit writes them as ``, ``
|
* Reddit writes them as ``, ``
|
||||||
* or `` — a token rather than an address. In every case
|
* or `` — a token rather than an address. Usually the
|
||||||
* the token is a key in that same comment's `media_metadata`, which is where
|
* token is a key in that same comment's `media_metadata`, which is where the
|
||||||
* the real URL is, so one lookup covers all three and none of them needs
|
* real URL is, so one lookup covers all three and none of them needs naming
|
||||||
* naming here.
|
* 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
|
* A target that is already an address is not a key, so it falls through
|
||||||
* untouched.
|
* untouched.
|
||||||
@@ -208,15 +223,17 @@ export function resolveInlineImages(
|
|||||||
body: string,
|
body: string,
|
||||||
meta: Record<string, MediaMeta> | undefined,
|
meta: Record<string, MediaMeta> | undefined,
|
||||||
): string {
|
): string {
|
||||||
if (!meta) return body;
|
|
||||||
|
|
||||||
return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => {
|
return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => {
|
||||||
const entry = meta[token];
|
const entry = meta?.[token];
|
||||||
if (!entry || entry.status !== 'valid') return whole;
|
if (entry?.status === 'valid') {
|
||||||
// An animated one has both; the GIF plays in an `<img>` on its own, which
|
// An animated one has both; the GIF plays in an `<img>` on its own,
|
||||||
// an MP4 does not.
|
// which an MP4 does not.
|
||||||
const url = entry.s?.gif ?? entry.s?.u;
|
const url = entry.s?.gif ?? entry.s?.u;
|
||||||
return url ? `` : whole;
|
if (url) return ``;
|
||||||
|
}
|
||||||
|
|
||||||
|
const giphy = GIPHY_TOKEN.exec(token)?.[1];
|
||||||
|
return giphy ? `` : whole;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { ResolveError, type Media, type Post, type Segment } from '../types.ts';
|
import { ResolveError, type Post, type Segment } from '../types.ts';
|
||||||
import { readOpenGraph } from '../browser/capture.ts';
|
import { readOpenGraph } from '../browser/capture.ts';
|
||||||
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders, type MetaMediaNode } from './meta-media.ts';
|
import {
|
||||||
|
findCaption,
|
||||||
|
findMetaMedia,
|
||||||
|
mediaFromMetaNode,
|
||||||
|
mediaFromOpenGraph,
|
||||||
|
type MetaMediaNode,
|
||||||
|
} from './meta-media.ts';
|
||||||
import { hostMatcher } from './url.ts';
|
import { hostMatcher } from './url.ts';
|
||||||
import type { PlatformSpec, ResolveContext } from './types.ts';
|
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||||
|
|
||||||
@@ -129,19 +135,7 @@ function segmentOf(post: ThreadsPost, isAnchor: boolean): Segment {
|
|||||||
/** The floor, when the structured payload is not there to be read. */
|
/** The floor, when the structured payload is not there to be read. */
|
||||||
async function fromOpenGraph(ctx: ResolveContext): Promise<Segment | undefined> {
|
async function fromOpenGraph(ctx: ResolveContext): Promise<Segment | undefined> {
|
||||||
const og = await readOpenGraph(ctx.page);
|
const og = await readOpenGraph(ctx.page);
|
||||||
const fetchHeaders = metaHeaders(REFERER);
|
const media = mediaFromOpenGraph(og, 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'];
|
const text = og['og:description'];
|
||||||
if (media.length === 0 && !text) return undefined;
|
if (media.length === 0 && !text) return undefined;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
const TRACKING_PARAMS = new Set([
|
const TRACKING_PARAMS = new Set([
|
||||||
'igsh', 'igshid', 'img_index', 'fbclid', 'gclid', 'mibextid',
|
'igsh', 'igshid', 'img_index', 'fbclid', 'gclid', 'mibextid',
|
||||||
|
'rdid', 'share_url', 'fs',
|
||||||
'ref_src', 'ref_url', 'ref', 'source', 'checksum', 'social_sharing',
|
'ref_src', 'ref_url', 'ref', 'source', 'checksum', 'social_sharing',
|
||||||
's', 't', 'si', 'xmt', '_r', '_t', '_d',
|
's', 't', 'si', 'xmt', '_r', '_t', '_d',
|
||||||
'is_from_webapp', 'sender_device', 'sender_web_id', 'web_id',
|
'is_from_webapp', 'sender_device', 'sender_web_id', 'web_id',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function renderChallenge(challenge: Challenge, box: Box | undefined): str
|
|||||||
<footer class="post__foot">
|
<footer class="post__foot">
|
||||||
<div class="original">
|
<div class="original">
|
||||||
<button type="button" class="copy" id="give-up">Give up and show me the link</button>
|
<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">
|
<a class="original__open" href="${challenge.originalUrl}" rel="noopener noreferrer nofollow" target="_blank" hidden>
|
||||||
Open on ${challenge.platformLabel}
|
Open on ${challenge.platformLabel}
|
||||||
</a>
|
</a>
|
||||||
<p class="original__url"><code>${challenge.originalUrl}</code></p>
|
<p class="original__url"><code>${challenge.originalUrl}</code></p>
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export function renderIndex(platforms: readonly PlatformSpec[]): string {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>`;
|
</article>`;
|
||||||
|
|
||||||
return layout('antisocial', body);
|
return layout('antisocial', body);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const ACCENT: Record<string, { color: string; monogram: string }> = {
|
|||||||
x: { color: '#0f1419', monogram: '✕' },
|
x: { color: '#0f1419', monogram: '✕' },
|
||||||
threads: { color: '#3b3b3b', monogram: '@' },
|
threads: { color: '#3b3b3b', monogram: '@' },
|
||||||
instagram: { color: '#c13584', monogram: 'IG' },
|
instagram: { color: '#c13584', monogram: 'IG' },
|
||||||
|
facebook: { color: '#0866ff', monogram: 'f' },
|
||||||
tiktok: { color: '#fe2c55', monogram: '♪' },
|
tiktok: { color: '#fe2c55', monogram: '♪' },
|
||||||
bluesky: { color: '#0085ff', monogram: 'B' },
|
bluesky: { color: '#0085ff', monogram: 'B' },
|
||||||
reddit: { color: '#ff4500', monogram: 'r' },
|
reddit: { color: '#ff4500', monogram: 'r' },
|
||||||
@@ -43,14 +44,19 @@ export function badge(platform: string, label: string): Raw {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The copy control, plus the URL itself so it is always selectable even if
|
/** The copy control, plus the URL itself so it is always selectable even if
|
||||||
* the clipboard API is unavailable. */
|
* 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. */
|
||||||
export function originalUrlBlock(originalUrl: string, platformLabel: string): Raw {
|
export function originalUrlBlock(originalUrl: string, platformLabel: string): Raw {
|
||||||
return html`<div class="original">
|
return html`<div class="original">
|
||||||
<button type="button" class="copy" data-url="${originalUrl}">
|
<button type="button" class="copy" data-url="${originalUrl}">
|
||||||
<span class="copy__idle">Copy original link</span>
|
<span class="copy__idle">Copy original link</span>
|
||||||
<span class="copy__done" hidden>Copied</span>
|
<span class="copy__done" hidden>Copied</span>
|
||||||
</button>
|
</button>
|
||||||
<a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank">Open on ${platformLabel}</a>
|
<a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank" hidden>Open on ${platformLabel}</a>
|
||||||
<p class="original__url"><code>${originalUrl}</code></p>
|
<p class="original__url"><code>${originalUrl}</code></p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -14,11 +14,29 @@ function aspect(item: Media): string {
|
|||||||
return item.width && item.height ? `aspect-ratio: ${item.width} / ${item.height};` : '';
|
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 {
|
function renderItem(item: Media, index: number): Raw {
|
||||||
const src = proxyUrlFor(item);
|
const src = proxyUrlFor(item);
|
||||||
|
|
||||||
if (item.kind === 'video') {
|
if (item.kind === 'video') {
|
||||||
const poster = item.poster ? proxyUrlFor(item.poster) : undefined;
|
const poster = item.poster ? proxyUrlFor(item.poster) : placeholderPoster(item);
|
||||||
return html`<figure class="item item--video" data-index="${index}">
|
return html`<figure class="item item--video" data-index="${index}">
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const PROFILE_BASE: Record<PlatformId, string> = {
|
|||||||
x: 'https://x.com/',
|
x: 'https://x.com/',
|
||||||
threads: 'https://www.threads.com/@',
|
threads: 'https://www.threads.com/@',
|
||||||
instagram: 'https://www.instagram.com/',
|
instagram: 'https://www.instagram.com/',
|
||||||
|
facebook: 'https://www.facebook.com/',
|
||||||
tiktok: 'https://www.tiktok.com/@',
|
tiktok: 'https://www.tiktok.com/@',
|
||||||
bluesky: 'https://bsky.app/profile/',
|
bluesky: 'https://bsky.app/profile/',
|
||||||
reddit: 'https://www.reddit.com/user/',
|
reddit: 'https://www.reddit.com/user/',
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
/** The platforms antisocial understands. */
|
/** The platforms antisocial understands. */
|
||||||
export type PlatformId = 'x' | 'threads' | 'instagram' | 'tiktok' | 'bluesky' | 'reddit';
|
export type PlatformId =
|
||||||
|
| 'x' | 'threads' | 'instagram' | 'facebook' | 'tiktok' | 'bluesky' | 'reddit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Something fetchable that lives on someone else's CDN.
|
* Something fetchable that lives on someone else's CDN.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
+4540
File diff suppressed because it is too large
Load Diff
Vendored
+775
@@ -0,0 +1,775 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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 aren’t 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
+2662
File diff suppressed because it is too large
Load Diff
+25
-1
@@ -1,6 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { findCaption, findMetaMedia, mediaFromMetaNode } from '../src/platforms/meta-media.ts';
|
import {
|
||||||
|
findCaption,
|
||||||
|
findMetaMedia,
|
||||||
|
mediaFromMetaNode,
|
||||||
|
mediaFromOpenGraph,
|
||||||
|
} from '../src/platforms/meta-media.ts';
|
||||||
import { fixture } from './helpers.ts';
|
import { fixture } from './helpers.ts';
|
||||||
|
|
||||||
const REFERER = 'https://www.threads.com/';
|
const REFERER = 'https://www.threads.com/';
|
||||||
@@ -68,3 +73,22 @@ 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: { text: '' } } }), undefined);
|
||||||
assert.equal(findCaption({ x: { caption: null } }), 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), []);
|
||||||
|
});
|
||||||
|
|||||||
+55
-1
@@ -56,6 +56,7 @@ test('a video with sound is the HLS playlist, because the MP4 has no audio track
|
|||||||
duration: 42,
|
duration: 42,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
preview: { images: [{ source: { url: 'https://external-preview.redd.it/still.png', width: 1920, height: 1080 } }] },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(media, [
|
assert.deepEqual(media, [
|
||||||
@@ -67,6 +68,9 @@ test('a video with sound is the HLS playlist, because the MP4 has no audio track
|
|||||||
width: 1920,
|
width: 1920,
|
||||||
height: 1080,
|
height: 1080,
|
||||||
durationSec: 42,
|
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' },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -224,13 +228,63 @@ test('a target that is already an address is left alone', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a token with no entry, or a broken one, is not invented', () => {
|
test('a token with no entry, or a broken one, is not invented', () => {
|
||||||
assert.equal(resolveInlineImages('', GIPHY), '');
|
// 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('', GIPHY), '');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveInlineImages('', { gone: { status: 'failed', e: 'Image' } }),
|
resolveInlineImages('', { gone: { status: 'failed', e: 'Image' } }),
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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('', undefined),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveInlineImages('', GIPHY),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the metadata still wins where there is any, being what Reddit will serve', () => {
|
||||||
|
assert.match(resolveInlineImages('', GIPHY), /redd\.it/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a variant name after the id is dropped, not all of them being served', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveInlineImages('', undefined),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(``, undefined), ``);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
test('inline images survive the walk into the comment tree', () => {
|
||||||
const { comments } = commentsFrom({
|
const { comments } = commentsFrom({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
+26
-1
@@ -90,6 +90,27 @@ test('a video gets native controls and a source, not an iframe', () => {
|
|||||||
assert.ok(!page.includes('<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', () => {
|
test('the copy button carries the clean original URL, and so does the page text', () => {
|
||||||
const page = renderPost(post());
|
const page = renderPost(post());
|
||||||
assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"'));
|
assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"'));
|
||||||
@@ -105,8 +126,12 @@ test('a failure still hands the link back', () => {
|
|||||||
detail: 'TikTok showed a verification puzzle instead of the post.',
|
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('data-url="https://www.tiktok.com/@a/video/1"'));
|
||||||
assert.ok(page.includes('Open on TikTok'));
|
assert.ok(page.includes('<code>https://www.tiktok.com/@a/video/1</code>'));
|
||||||
assert.ok(page.includes('verification puzzle'));
|
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 {
|
function redditPost(overrides: Partial<Post> = {}): Post {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
// Source imports carry the real `.ts` specifier so `node --experimental-
|
// Source imports carry the real `.ts` specifier so `node --experimental-
|
||||||
// strip-types` can run the tree directly for dev, tests and the resolve
|
// strip-types` can run the tree directly for dev, tests and the resolve
|
||||||
// CLI. tsc rewrites them to `.js` on the way into dist.
|
// 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,
|
"allowImportingTsExtensions": true,
|
||||||
"rewriteRelativeImportExtensions": true,
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user