Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05455a86da | ||
|
|
4c78ce8a27
|
||
|
|
d9fa2f5768 | ||
|
|
dfabfc18d6 | ||
|
|
3afdf6ffe2 | ||
|
|
5b4378a838
|
||
|
|
4669fe0b6a
|
||
|
|
ebfcc9ebf2 | ||
|
|
8fc7a83f8e | ||
|
|
91d4c2da50
|
||
|
|
899b6e38d8
|
||
|
|
abf8ec317c
|
||
|
|
3ff7375320
|
||
|
|
43dc10e1dc
|
||
|
|
b94c43a10c
|
||
|
|
b9b56e2195
|
||
|
|
031101c382
|
||
|
|
0db18547c8
|
||
|
|
9abc0eaf62
|
||
|
|
24db9be5af
|
||
|
|
503d8a8dec
|
||
|
|
2ccc7bd618
|
||
|
|
6325f0ff32
|
||
|
|
361cdbd966
|
||
|
|
df50fa7e93
|
||
|
|
e49f1ce88b
|
||
|
|
60a9468875
|
||
|
|
2a8f71adc7
|
@@ -141,3 +141,136 @@ jobs:
|
|||||||
- name: Log out
|
- name: Log out
|
||||||
if: always() && github.event_name != 'pull_request'
|
if: always() && github.event_name != 'pull_request'
|
||||||
run: docker logout "${{ vars.REGISTRY }}" || true
|
run: docker logout "${{ vars.REGISTRY }}" || true
|
||||||
|
|
||||||
|
# Once a release is out, the version in package.json has already shipped.
|
||||||
|
# Moving it on to the next patch means the working tree is never sitting on
|
||||||
|
# a number that is published and immutable, and that a build from main is
|
||||||
|
# always identifiable as "after 1.2.0" rather than "1.2.0, but not really".
|
||||||
|
#
|
||||||
|
# `needs: build` is the point of putting this here rather than in a workflow
|
||||||
|
# of its own: a version that failed to publish has not been released, and
|
||||||
|
# bumping past it would say it had.
|
||||||
|
bump:
|
||||||
|
name: Move the working version on
|
||||||
|
needs: build
|
||||||
|
# Tags only, and only final ones. A prerelease has not shipped the version
|
||||||
|
# it is a candidate for, so there is nothing yet to move past.
|
||||||
|
if: github.ref_type == 'tag' && !contains(github.ref_name, '-')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# npm does the bump, so this one job wants node. A job in a container is
|
||||||
|
# given `sh -e {0}` as its shell rather than the bash the runner's own jobs
|
||||||
|
# get, and dash has neither `pipefail` nor the `10#` below — which failed
|
||||||
|
# the first line of the first step the one time this ran. node:22 is Debian
|
||||||
|
# and carries bash, so asking for it keeps these scripts the same as the
|
||||||
|
# ones in the job above.
|
||||||
|
container:
|
||||||
|
image: node:22
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
# The tag names a commit in main's history, but the bump belongs on the
|
||||||
|
# branch, so this checks out main rather than the tag. The full history
|
||||||
|
# because a shallow clone cannot reliably push a branch back.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Work out the next patch version
|
||||||
|
id: next
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
version="${{ github.ref_name }}"
|
||||||
|
version="${version#v}"
|
||||||
|
|
||||||
|
major="${version%%.*}"
|
||||||
|
rest="${version#*.}"
|
||||||
|
minor="${rest%%.*}"
|
||||||
|
patch="${rest##*.}"
|
||||||
|
|
||||||
|
# `10#` forces base ten: a patch number written 08 would otherwise be
|
||||||
|
# read as octal and fail to parse.
|
||||||
|
next="${major}.${minor}.$((10#${patch} + 1))"
|
||||||
|
|
||||||
|
echo "next=${next}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Released ${version}; the working version becomes ${next}"
|
||||||
|
|
||||||
|
- name: Bump package.json
|
||||||
|
id: bump
|
||||||
|
env:
|
||||||
|
NEXT: ${{ steps.next.outputs.next }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
current="$(node -p "require('./package.json').version")"
|
||||||
|
if [ "${current}" = "${NEXT}" ]; then
|
||||||
|
echo "package.json is already ${NEXT}; nothing to do."
|
||||||
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# npm rather than editing the file: the version is in the lockfile
|
||||||
|
# too, in more than one place, and they have to agree.
|
||||||
|
npm version "${NEXT}" --no-git-tag-version --allow-same-version >/dev/null
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "package.json ${current} -> ${NEXT}"
|
||||||
|
|
||||||
|
# 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'
|
||||||
|
env:
|
||||||
|
NEXT: ${{ steps.next.outputs.next }}
|
||||||
|
RELEASED: ${{ github.ref_name }}
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
branch="release/bump-${NEXT}"
|
||||||
|
|
||||||
|
# A name that is not a person, and a reserved address that can never
|
||||||
|
# resolve to one. Nothing here names the instance it runs on.
|
||||||
|
git config user.name 'Release bot'
|
||||||
|
git config user.email '[email protected]'
|
||||||
|
|
||||||
|
git checkout -b "${branch}"
|
||||||
|
git add package.json package-lock.json
|
||||||
|
git commit -m "Set the working version to ${NEXT}"
|
||||||
|
git push origin "${branch}"
|
||||||
|
|
||||||
|
# node rather than jq to build the request body: jq is not in this
|
||||||
|
# image, and node is the one thing that certainly is.
|
||||||
|
payload="$(BRANCH="${branch}" node -e 'process.stdout.write(JSON.stringify({
|
||||||
|
head: process.env.BRANCH,
|
||||||
|
base: "main",
|
||||||
|
title: `Set the working version to ${process.env.NEXT}`,
|
||||||
|
body: `${process.env.RELEASED} has shipped, so the tree was left on a version that is published and immutable. This moves it on to ${process.env.NEXT}, which is deliberately not a version that exists.`,
|
||||||
|
}))')"
|
||||||
|
|
||||||
|
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
|
response="$(mktemp)"
|
||||||
|
code="$(curl -sS -o "${response}" -w '%{http_code}' \
|
||||||
|
-X POST "${api}/pulls" \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "${payload}")"
|
||||||
|
|
||||||
|
case "${code}" in
|
||||||
|
201) echo "Opened ${branch} against main." ;;
|
||||||
|
# A release re-run that got this far: the branch and its pull
|
||||||
|
# request are already there, which is the state we wanted anyway.
|
||||||
|
409) echo "A pull request for ${branch} is already open." ;;
|
||||||
|
*)
|
||||||
|
echo "Could not open the pull request (HTTP ${code}):" >&2
|
||||||
|
cat "${response}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# 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 and Bluesky into `/<prefix>/<original path>`;
|
links to X, Threads, Instagram, TikTok, Bluesky and Reddit into
|
||||||
this resolves the post by driving a real headless Chromium and renders the media, the
|
`/<prefix>/<original path>`; this resolves the post by driving a real headless Chromium
|
||||||
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.
|
||||||
|
|
||||||
Built for one person on a private network, with no authentication. `README.md` has the
|
Built for one person on a private network, with no authentication. `README.md` has the
|
||||||
rewrite rules and the user-facing description. This file is the working context.
|
rewrite rules and the user-facing description. This file is the working context.
|
||||||
@@ -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. |
|
||||||
|
| 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.**
|
||||||
|
|
||||||
@@ -62,6 +63,21 @@ Request → `src/routes/post.ts` → `src/platforms/index.ts` maps prefix to ada
|
|||||||
Adding a platform is one file in `src/platforms/` plus one row in the table in
|
Adding a platform is one file in `src/platforms/` plus one row in the table in
|
||||||
`index.ts`. Everything downstream already handles a `Post`.
|
`index.ts`. Everything downstream already handles a `Post`.
|
||||||
|
|
||||||
|
A `Post` is a list of `Segment`s, not a single body. Most platforms produce one
|
||||||
|
(`oneSegment` in `types.ts`); Bluesky and Threads produce the author's whole chain,
|
||||||
|
with `isAnchor` marking the post that was linked — which need not be the first.
|
||||||
|
|
||||||
|
`Segment.quoted` is a post inside a post — someone else's words and pictures, kept
|
||||||
|
under their own name. X and Bluesky fill it in. Both used to lift the quoted post's
|
||||||
|
media out and show it as the quoter's own, which is the bug to avoid reintroducing:
|
||||||
|
a quoted picture without the name attached to it is a false attribution.
|
||||||
|
|
||||||
|
`Post.comments` is a separate thing and a tree, not a chain. Only Reddit fills it in,
|
||||||
|
because only there is the conversation usually the point of the link. It is a tree
|
||||||
|
rather than a flat list with depths because folding a comment has to take everything
|
||||||
|
under it along, and nesting is what makes that free — the renderer emits a `<details>`
|
||||||
|
per comment and gets collapsing without a line of JavaScript.
|
||||||
|
|
||||||
Things worth knowing before editing:
|
Things worth knowing before editing:
|
||||||
|
|
||||||
- **One Chromium, one context, persistent.** Cookies and dismissed banners accumulate on
|
- **One Chromium, one context, persistent.** Cookies and dismissed banners accumulate on
|
||||||
@@ -80,6 +96,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.
|
||||||
@@ -88,9 +113,27 @@ Things worth knowing before editing:
|
|||||||
|
|
||||||
- **Bluesky** — asks the public API directly (still through the browser context), so it
|
- **Bluesky** — asks the public API directly (still through the browser context), so it
|
||||||
is the most reliable. The web app calls `getPostThreadV2` now; the page fallback
|
is the most reliable. The web app calls `getPostThreadV2` now; the page fallback
|
||||||
deliberately matches only V1.
|
deliberately matches only V1. Threads are built by walking `parent` up and the
|
||||||
|
author's own `replies` down; `depth`/`parentHeight` are what make that possible, at
|
||||||
|
the cost of dragging the whole reply tree along (a few hundred KB on a busy post).
|
||||||
|
A quote fills in `Segment.quoted`. The record sits at `embed.record` for a plain quote
|
||||||
|
and at `embed.record.record` when the quoting post has media of its own
|
||||||
|
(`recordWithMedia`) — check both. A quote can also point at a feed, a list or a post
|
||||||
|
since deleted, which arrive in the same slot under a different `$type`, so only
|
||||||
|
`app.bsky.embed.record#viewRecord` is accepted. The quoted post's address has to be
|
||||||
|
rebuilt: `uri` is an `at://` nobody can open.
|
||||||
- **X** — the `platform.twitter.com` embed calls the syndication endpoint; we catch that
|
- **X** — the `platform.twitter.com` embed calls the syndication endpoint; we catch that
|
||||||
response. A quote post carries no media of its own, so the quoted post's media is used.
|
response. A quote post keeps the post it quotes whole, in `Segment.quoted` — author,
|
||||||
|
words and pictures — rather than lifting its media out; doing that put someone else's
|
||||||
|
picture under the quoter's name, and dropped the quoted words entirely. There is no
|
||||||
|
permalink in the payload, so the quoted post's URL is rebuilt from the handle and
|
||||||
|
`id_str`. Two things about the text: it arrives pre-escaped (`&`) and has to be
|
||||||
|
decoded, since everything downstream escapes again on the way out; and X staples a
|
||||||
|
`t.co` to the quoted post onto the end, which `display_text_range` trims — its indices
|
||||||
|
are UTF-16 units into the *escaped* text, so slice before decoding and do not split to
|
||||||
|
codepoints first. Every remaining link is a `t.co`, expanded from `entities.urls` —
|
||||||
|
matched on the shortlink text rather than by `indices`, which are offsets into a string
|
||||||
|
the expansion is changing the length of.
|
||||||
- **Instagram** — the least reliable. It ships the structured payload only some of the
|
- **Instagram** — the least reliable. It ships the structured payload only some of the
|
||||||
time, and refuses some posts from the embed entirely ("the link may be broken"). The
|
time, and refuses some posts from the embed entirely ("the link may be broken"). The
|
||||||
DOM is the floor and only ever shows the first carousel item. Reels hydrate their
|
DOM is the floor and only ever shows the first carousel item. Reels hydrate their
|
||||||
@@ -100,9 +143,43 @@ Things worth knowing before editing:
|
|||||||
carries an `itemStruct`; do not hardcode the key, photo posts use a different one.
|
carries an `itemStruct`; do not hardcode the key, photo posts use a different one.
|
||||||
Its CDN needs `Referer` *and* cookies. Short `vm.`/`vt.` links arrive as a bare path
|
Its CDN needs `Referer` *and* cookies. Short `vm.`/`vt.` links arrive as a bare path
|
||||||
segment and are rebuilt in `buildOriginalUrl`.
|
segment and are rebuilt in `buildOriginalUrl`.
|
||||||
|
- **Reddit** — the `.json` twin of any post URL is the whole post plus the first page of
|
||||||
|
comments in one response, which is far better than anything the page gives up, so that
|
||||||
|
is the only layer that normally runs. A cold profile gets a JavaScript challenge
|
||||||
|
instead of JSON; an ordinary navigation solves it by itself, so the adapter navigates
|
||||||
|
once and retries, and the cookie serves every later post. The floor is
|
||||||
|
`shreddit-comment` elements, which are flat with a `depth` attribute — `treeFromDepths`
|
||||||
|
rebuilds the nesting. `replies` is `""` and not an object when there are none.
|
||||||
|
Video: `fallback_url` is the *video track alone* whenever `has_audio` is true, so a
|
||||||
|
post with sound has to use `hls_url`, direct and unproxied; a silent one gets the
|
||||||
|
proxied MP4. `scrubber_media_url` is not a poster — it is a second MP4 for the
|
||||||
|
timeline thumbnails, and the still is in `preview.images` — which belongs to both
|
||||||
|
forms: the HLS one used to go without, and showed an empty box where a silent post
|
||||||
|
showed a frame. A gallery's pictures are in `media_metadata`, keyed and unordered;
|
||||||
|
their order is only in `gallery_data`. Comment
|
||||||
|
bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put
|
||||||
|
back the constructs we chose to support, never `body_html`. An image in a comment is
|
||||||
|
written as a token rather than an address — ``,
|
||||||
|
``, `` — and the token is usually a key
|
||||||
|
in that same comment's own `media_metadata`, so `resolveInlineImages` is one lookup
|
||||||
|
rather than three special cases. Usually: plenty of comments carry a Giphy token and
|
||||||
|
no `media_metadata` at all, and Giphy is the one of the three whose id means something
|
||||||
|
off Reddit, so that token alone falls back to `i.giphy.com/media/<id>/giphy.gif`. A
|
||||||
|
variant name after the id is dropped — Giphy does not serve every variant of every
|
||||||
|
gif. A bare `preview.redd.it` address pasted into a comment is in there too, keyed by
|
||||||
|
the id inside the URL. Prefer `s.gif` over `s.mp4` for an animated one: a GIF moves in
|
||||||
|
an `<img>` and an MP4 needs a player. An address
|
||||||
|
typed without a scheme counts as well, but only when it ends in an image extension —
|
||||||
|
the rule wants a host, a path *and* that extension, because comments are full of
|
||||||
|
dotted, slashed prose that must not turn into links.
|
||||||
- **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.
|
candidates in it. The page ships the linked post, the author's follow-ups, other
|
||||||
|
people's replies and unrelated recommendations all as flat `thread_items`
|
||||||
|
containers. A follow-up is the author replying to *themselves*, which is what
|
||||||
|
separates it from a stranger's reply carrying the same `reply_to_author`. The first
|
||||||
|
post of a chain replies to nothing, so it is only reachable by walking backwards
|
||||||
|
from the one that answers it.
|
||||||
|
|
||||||
## Verification puzzles
|
## Verification puzzles
|
||||||
|
|
||||||
@@ -121,6 +198,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
|
||||||
@@ -148,6 +229,23 @@ 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, opened as a pull
|
||||||
|
request by the `bump` job — so the number in the tree is never one that has already
|
||||||
|
shipped and been made immutable. It lives in `publish.yml` rather than a workflow of its
|
||||||
|
own so it can say `needs: build`: a version that failed to publish has not been
|
||||||
|
released, and bumping past it would claim otherwise. Prereleases are skipped, being
|
||||||
|
candidates for a version that has not shipped. The bump goes through `npm version`
|
||||||
|
rather than an edit in place, because the version is in the lockfile too, in more than
|
||||||
|
one place, and the two have to agree.
|
||||||
|
|
||||||
|
It arrives as a pull request rather than as a commit straight to main: pushing a branch
|
||||||
|
asks nothing of the task token beyond ordinary write access, so it does not matter what
|
||||||
|
protects main, and the changed `package.json` goes through a build before it lands.
|
||||||
|
Nothing pushes to main, so no `[skip ci]` is needed to stop the bump rebuilding the
|
||||||
|
image just published. The job is the only one that runs in a container (`node:22`, for
|
||||||
|
npm), and a job in a container is handed `sh`, not bash — hence the explicit
|
||||||
|
`shell: bash`, without which `set -o pipefail` fails the first line of the first step.
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
- **Chromium needs more than the default 64Mi `/dev/shm`** or it crashes. Mount a
|
- **Chromium needs more than the default 64Mi `/dev/shm`** or it crashes. Mount a
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Reads social posts back to you without the app.
|
Reads social posts back to you without the app.
|
||||||
|
|
||||||
Links to X, Threads, Instagram, TikTok and Bluesky get shared constantly, and opening
|
Links to X, Threads, Instagram, TikTok, Bluesky and Reddit get shared constantly, and
|
||||||
one means an app interstitial, a login wall, a feed you didn't ask for, and a pile of
|
opening one means an app interstitial, a login wall, a feed you didn't ask for, and a
|
||||||
tracking. antisocial is the other half of a StopTheMadness rewrite rule: the link gets
|
pile of tracking. antisocial is the other half of a StopTheMadness rewrite rule: the link gets
|
||||||
redirected here, and you get the post — the media and the words — plus a badge saying
|
redirected here, and you get the post — the media and the words — plus a badge saying
|
||||||
where it came from and a button to copy the original URL if you do want to go there.
|
where it came from and a button to copy the original URL if you do want to go there.
|
||||||
|
|
||||||
@@ -21,23 +21,86 @@ readable in your history.
|
|||||||
|
|
||||||
Replace `antisocial.example.com` with wherever you are running it.
|
Replace `antisocial.example.com` with wherever you are running it.
|
||||||
|
|
||||||
| Platform | Find | Replace |
|
Each rule is two fields. Both are on their own line below, and neither needs any
|
||||||
| --- | --- | --- |
|
escaping — copy them straight out of this file.
|
||||||
| X | `^https://(?:www\.\|mobile\.)?(?:x\|twitter)\.com/(.*)$` | `https://antisocial.example.com/x/$1` |
|
|
||||||
| Threads | `^https://(?:www\.)?threads\.(?:net\|com)/(.*)$` | `https://antisocial.example.com/threads/$1` |
|
```text
|
||||||
| Instagram | `^https://(?:www\.)?instagram\.com/(.*)$` | `https://antisocial.example.com/ig/$1` |
|
# X
|
||||||
| TikTok | `^https://(?:www\.\|vm\.\|vt\.)?tiktok\.com/(.*)$` | `https://antisocial.example.com/tiktok/$1` |
|
/^https:\/\/(?:www\.|mobile\.)?(?:x|twitter)\.com\/(.*)$/
|
||||||
| Bluesky | `^https://bsky\.app/(.*)$` | `https://antisocial.example.com/bsky/$1` |
|
https://antisocial.example.com/x/$1
|
||||||
|
|
||||||
|
# Threads
|
||||||
|
/^https:\/\/(?:www\.)?threads\.(?:net|com)\/(.*)$/
|
||||||
|
https://antisocial.example.com/threads/$1
|
||||||
|
|
||||||
|
# Instagram
|
||||||
|
/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/
|
||||||
|
https://antisocial.example.com/ig/$1
|
||||||
|
|
||||||
|
# TikTok
|
||||||
|
/^https:\/\/(?:www\.|vm\.|vt\.)?tiktok\.com\/(.*)$/
|
||||||
|
https://antisocial.example.com/tiktok/$1
|
||||||
|
|
||||||
|
# Bluesky
|
||||||
|
/^https:\/\/bsky\.app\/(.*)$/
|
||||||
|
https://antisocial.example.com/bsky/$1
|
||||||
|
|
||||||
|
# Reddit
|
||||||
|
/^https:\/\/(?:www\.|old\.|new\.|np\.|m\.)?reddit\.com\/(.*)$/
|
||||||
|
https://antisocial.example.com/reddit/$1
|
||||||
|
|
||||||
|
# Reddit short links
|
||||||
|
/^https:\/\/redd\.it\/(.*)$/
|
||||||
|
https://antisocial.example.com/reddit/$1
|
||||||
|
```
|
||||||
|
|
||||||
|
A code block rather than a table, because a table cell cannot hold a bare `|` — it has
|
||||||
|
to be written `\|`, which renders correctly and copies wrongly. The alternation in these
|
||||||
|
rules is full of them, and a regex whose pipes arrive as literal pipes matches nothing
|
||||||
|
and says nothing about why.
|
||||||
|
|
||||||
So `https://x.com/user/status/123` becomes
|
So `https://x.com/user/status/123` becomes
|
||||||
`https://antisocial.example.com/x/user/status/123`.
|
`https://antisocial.example.com/x/user/status/123`.
|
||||||
|
|
||||||
Tracking parameters (`igsh`, `utm_*`, `s`, `t`, and friends) are stripped on arrival, so
|
Tracking parameters (`igsh`, `utm_*`, `share_id`, `s`, `t`, and friends) are stripped on
|
||||||
the URL the copy button gives back is the clean one. TikTok `vm.`/`vt.` share codes lose
|
arrival, so the URL the copy button gives back is the clean one. TikTok `vm.`/`vt.`
|
||||||
their subdomain in the rewrite; a single opaque path segment is recognised as a share
|
share codes lose their subdomain in the rewrite; a single opaque path segment is
|
||||||
code and rebuilt as `vm.tiktok.com/<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
|
||||||
|
post it points at, and that permalink — not the opaque share code — is what the copy
|
||||||
|
button hands back.
|
||||||
|
|
||||||
`/` serves this table with the live hostnames, if you'd rather read it there.
|
`/` serves these rules with the live hostname already filled in, if you'd rather copy
|
||||||
|
them from there.
|
||||||
|
|
||||||
|
## Opening the original
|
||||||
|
|
||||||
|
The rules are indiscriminate, which is the point — but that catches the way back out
|
||||||
|
too. Tapping "Open on Instagram" in the browser the rules are installed in redirects
|
||||||
|
straight back here, so the one button meant to get you to the app is the one button that
|
||||||
|
cannot.
|
||||||
|
|
||||||
|
The way past it is to hand the address to a *different* browser, by its own URL scheme.
|
||||||
|
`/` has a picker for which one:
|
||||||
|
|
||||||
|
| Browser | macOS | iOS |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Chrome | `googlechromes://…` | `googlechromes://…` |
|
||||||
|
| Edge | `microsoft-edge:https://…` | `microsoft-edge-https://…` |
|
||||||
|
| Orion | `orion://open-url?url=…` | `orion://open-url?url=…` |
|
||||||
|
| Firefox | — | `firefox://open-url?url=…` |
|
||||||
|
|
||||||
|
Firefox on macOS registers no scheme, so it is not offered there; nor is Safari itself,
|
||||||
|
which is the browser being escaped. The choice is kept in that browser's own
|
||||||
|
`localStorage` — not a cookie and not synced, because "which browser is installed" is a
|
||||||
|
fact about the device, not about you, and the phone's answer is not the Mac's.
|
||||||
|
|
||||||
|
Until a browser is chosen there is no "Open on …" button at all — the plain address is
|
||||||
|
the one thing it must not offer, since following it only comes back here. The markup
|
||||||
|
carries that address anyway, hidden, and the script swaps in the scheme and reveals the
|
||||||
|
button once there is somewhere to send it. So with JavaScript off, or with the default
|
||||||
|
left alone, the copy button and the selectable URL are the whole of it, which is all
|
||||||
|
that was ever load-bearing.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -54,13 +117,50 @@ Each adapter layers its extraction, most structured first:
|
|||||||
3. **The rendered DOM** — whatever is actually on screen is real.
|
3. **The rendered DOM** — whatever is actually on screen is real.
|
||||||
4. **Open Graph tags** — the floor, and enough to show something.
|
4. **Open Graph tags** — the floor, and enough to show something.
|
||||||
|
|
||||||
| Platform | Loads | Reads |
|
| Platform | Loads | Reads |
|
||||||
| --- | --- | --- |
|
| --------- | ---------------------------- | --------------------------------------------------------------- |
|
||||||
| 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>` |
|
||||||
| 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 |
|
||||||
|
|
||||||
|
On Bluesky and Threads people write in chains, so where the linked post is part
|
||||||
|
of one, the author's own follow-ups are shown with it, in the order they were
|
||||||
|
written, with the post you actually followed marked. Other people's replies are
|
||||||
|
left out — they are a conversation, not the thing that was shared, and on a busy
|
||||||
|
post there are hundreds of them.
|
||||||
|
|
||||||
|
A post that quotes another shows both, on X and on Bluesky. The quoted post gets its own
|
||||||
|
author, its own words and its own pictures, in a block inside the one quoting it —
|
||||||
|
because half the quote posts people share are someone answering a stranger and the other
|
||||||
|
half are someone continuing a thought from an earlier post, and neither reads with only
|
||||||
|
one side of it on the page. Showing the quoted picture on its own, which is what used to
|
||||||
|
happen, put it under the wrong person's name.
|
||||||
|
|
||||||
|
X links are un-shortened. Every link in a post is rewritten to a `t.co` before it is
|
||||||
|
stored, so left alone the page shows `t.co/QdJhOVu4En` and sends you through X's click
|
||||||
|
tracker to find out where it goes; the real address is in the payload alongside. The
|
||||||
|
shortlink X staples onto the end of a quote post is dropped rather than expanded, since
|
||||||
|
the post it points at is already on the page — a link the author put there on purpose
|
||||||
|
is kept.
|
||||||
|
|
||||||
|
On Reddit the thread under the post is usually the reason the link was shared, so
|
||||||
|
Reddit posts come with it: every comment the first page carried, nested the way it was
|
||||||
|
written. Each comment is a `<details>` element, so folding one takes its whole subtree
|
||||||
|
with it, works without JavaScript and works from the keyboard; a collapsed comment says
|
||||||
|
how many replies it is hiding. "Collapse all" is the one piece that needs the script,
|
||||||
|
which is why it only appears once the script has run. What was behind a _load more_ is
|
||||||
|
not fetched — that is a second page and often a third — but it is counted and said out
|
||||||
|
loud rather than quietly dropped.
|
||||||
|
|
||||||
|
Pictures inside comments are shown as pictures. Reddit writes them as a token rather
|
||||||
|
than an address — a Giphy id, a subreddit emote, or an image uploaded to the comment —
|
||||||
|
and all three are looked up in the comment's own metadata to find the real file. An
|
||||||
|
image address someone simply pasted is shown too, which on Reddit is how most of them
|
||||||
|
arrive. All of it goes through the same `/m/` proxy as everything else, so reading a
|
||||||
|
comment thread never has your browser talking to Reddit.
|
||||||
|
|
||||||
Media never gets linked straight at a CDN. Instagram and TikTok reject requests without
|
Media never gets linked straight at a CDN. Instagram and TikTok reject requests without
|
||||||
a matching `Referer` (and sometimes cookies), and proxying keeps your browser from
|
a matching `Referer` (and sometimes cookies), and proxying keeps your browser from
|
||||||
@@ -71,6 +171,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.
|
||||||
|
|
||||||
@@ -104,6 +208,11 @@ Known rough edges:
|
|||||||
carousel will come back as its first image only.
|
carousel will come back as its first image only.
|
||||||
- **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
|
||||||
|
profile gets a JavaScript challenge, which the page solves by itself on an ordinary
|
||||||
|
navigation; the adapter does that once and retries, and the cookie it leaves behind
|
||||||
|
serves every later post. If the JSON is still refused, the rendered page is read
|
||||||
|
instead — the same comments, without the scores Reddit is withholding.
|
||||||
|
|
||||||
## Verification puzzles
|
## Verification puzzles
|
||||||
|
|
||||||
@@ -156,17 +265,17 @@ ground for a project this size.
|
|||||||
|
|
||||||
Everything has a working default; the container needs none of it set.
|
Everything has a working default; the container needs none of it set.
|
||||||
|
|
||||||
| Variable | Default | |
|
| Variable | Default | |
|
||||||
| --- | --- | --- |
|
| ---------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------- |
|
||||||
| `PORT` / `HOST` | `8080` / `0.0.0.0` | |
|
| `PORT` / `HOST` | `8080` / `0.0.0.0` | |
|
||||||
| `PROFILE_DIR` | `./profile` (`/data/profile` in the image) | Chromium cookies and storage, persisted so the browser accumulates ordinary state |
|
| `PROFILE_DIR` | `./profile` (`/data/profile` in the image) | Chromium cookies and storage, persisted so the browser accumulates ordinary state |
|
||||||
| `MAX_CONCURRENT` | `2` | page loads at once; the rest queue |
|
| `MAX_CONCURRENT` | `2` | page loads at once; the rest queue |
|
||||||
| `NAVIGATION_TIMEOUT_MS` | `20000` | |
|
| `NAVIGATION_TIMEOUT_MS` | `20000` | |
|
||||||
| `RESOLVE_TIMEOUT_MS` | `30000` | whole resolve, including extraction |
|
| `RESOLVE_TIMEOUT_MS` | `30000` | whole resolve, including extraction |
|
||||||
| `CACHE_TTL_MS` / `CACHE_MAX` | `3600000` / `200` | resolved posts, in memory |
|
| `CACHE_TTL_MS` / `CACHE_MAX` | `3600000` / `200` | resolved posts, in memory |
|
||||||
| `MEDIA_TOKEN_TTL_MS` | `21600000` | how long a `/m/` reference stays valid |
|
| `MEDIA_TOKEN_TTL_MS` | `21600000` | how long a `/m/` reference stays valid |
|
||||||
| `PUBLIC_ORIGIN` | `http://localhost:8080` | only used to print the rules on `/` |
|
| `PUBLIC_ORIGIN` | `http://localhost:8080` | only used to print the rules on `/` |
|
||||||
| `LOG_LEVEL` | `info` | |
|
| `LOG_LEVEL` | `info` | |
|
||||||
|
|
||||||
## Adding a platform
|
## Adding a platform
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "antisocial",
|
"name": "antisocial",
|
||||||
"version": "0.1.0",
|
"version": "1.2.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "antisocial",
|
"name": "antisocial",
|
||||||
"version": "0.1.0",
|
"version": "1.2.4",
|
||||||
"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": "0.1.0",
|
"version": "1.2.4",
|
||||||
"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",
|
||||||
|
|||||||
+358
-6
@@ -86,7 +86,9 @@ main { max-width: 680px; margin: 0 auto; }
|
|||||||
|
|
||||||
/* ---------- media ---------- */
|
/* ---------- media ---------- */
|
||||||
|
|
||||||
.media { border-block: 1px solid var(--line); background: #000; }
|
/* Card-coloured, not black: whatever space the media does not fill should
|
||||||
|
read as part of the page rather than as a border around it. */
|
||||||
|
.media { border-block: 1px solid var(--line); background: var(--card); }
|
||||||
.post__head + .media, .text + .media { border-top: 1px solid var(--line); }
|
.post__head + .media, .text + .media { border-top: 1px solid var(--line); }
|
||||||
|
|
||||||
.media__bar {
|
.media__bar {
|
||||||
@@ -135,14 +137,21 @@ main { max-width: 680px; margin: 0 auto; }
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sized by its own proportions rather than stretched to the column: a
|
||||||
|
portrait video filling the width is mostly empty space either side of it.
|
||||||
|
The cap keeps any single item within the window. */
|
||||||
.item img, .item video {
|
.item img, .item video {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: auto;
|
||||||
max-height: 78vh;
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
max-height: 70dvh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: #000;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item__note {
|
.item__note {
|
||||||
@@ -162,9 +171,31 @@ main { max-width: 680px; margin: 0 auto; }
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media[data-view="grid"] .item { aspect-ratio: 1 / 1 !important; cursor: zoom-in; }
|
.media[data-view="grid"] .item {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
cursor: zoom-in;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Filled absolutely rather than with height: 100%. The cell's height comes
|
||||||
|
* from its aspect-ratio, which a percentage height will not resolve against,
|
||||||
|
* and `aspect-ratio: auto` means "use the image's own" rather than "ignore
|
||||||
|
* ratios" -- between them a portrait thumbnail ends up twice the height of
|
||||||
|
* its cell.
|
||||||
|
*/
|
||||||
.media[data-view="grid"] .item img,
|
.media[data-view="grid"] .item img,
|
||||||
.media[data-view="grid"] .item video { height: 100%; max-height: none; object-fit: cover; }
|
.media[data-view="grid"] .item video {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
min-height: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
aspect-ratio: auto !important;
|
||||||
|
}
|
||||||
.media[data-view="grid"] .item__note { display: none; }
|
.media[data-view="grid"] .item__note { display: none; }
|
||||||
|
|
||||||
/* ---------- footer ---------- */
|
/* ---------- footer ---------- */
|
||||||
@@ -211,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); }
|
||||||
@@ -247,3 +302,300 @@ main { max-width: 680px; margin: 0 auto; }
|
|||||||
color: #e9ebee;
|
color: #e9ebee;
|
||||||
background: rgba(0, 0, 0, 0.65);
|
background: rgba(0, 0, 0, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- threads ---------- */
|
||||||
|
|
||||||
|
.segment + .segment { border-top: 1px solid var(--line); }
|
||||||
|
|
||||||
|
/* A chain reads as one column of posts, so the media in each needs its own
|
||||||
|
top edge only when text sits above it. */
|
||||||
|
.segment .text + .media { border-top: 1px solid var(--line); }
|
||||||
|
|
||||||
|
.post[data-segments] .segment { position: relative; }
|
||||||
|
|
||||||
|
.segment__mark {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 14px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post[data-segments] .segment[data-anchor="true"] {
|
||||||
|
/* The link may point at the middle of a thread, so say which one it was
|
||||||
|
without hiding the rest. */
|
||||||
|
background: color-mix(in srgb, var(--accent, #888) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- fitting the window ---------- */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* On a large screen a single post should be readable without scrolling to
|
||||||
|
* find it -- a video especially, which you can full-screen if you want it
|
||||||
|
* bigger.
|
||||||
|
*
|
||||||
|
* The cap is the window less the chrome around the media: header, caption and
|
||||||
|
* footer come to roughly 270-350px on the posts this serves. Deriving it
|
||||||
|
* instead of subtracting a constant is the obvious thing to try and does not
|
||||||
|
* work: the card is sized by its contents, so a percentage height inside it
|
||||||
|
* resolves to auto and clamps nothing.
|
||||||
|
*
|
||||||
|
* A thread is a column of several posts and cannot fit whatever we do, so it
|
||||||
|
* keeps the looser cap above and scrolls.
|
||||||
|
*/
|
||||||
|
@media (min-width: 700px) and (min-height: 560px) {
|
||||||
|
.post:not([data-segments]) .item img,
|
||||||
|
.post:not([data-segments]) .item video {
|
||||||
|
max-height: calc(100vh - 350px);
|
||||||
|
max-height: calc(100dvh - 350px);
|
||||||
|
/* Below that the media would be too small to be worth showing; better to
|
||||||
|
let a very long caption scroll. */
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- a post with a headline ---------- */
|
||||||
|
|
||||||
|
/* Reddit is the one platform where the post has a title and the body is
|
||||||
|
optional, so the headline has to carry on its own. */
|
||||||
|
.text__title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text__title:only-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* ---------- comments ---------- */
|
||||||
|
|
||||||
|
.thread {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread__title { margin: 0; font-size: 15px; font-weight: 650; }
|
||||||
|
.thread__count { font-size: 12px; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
.thread__toggle {
|
||||||
|
appearance: none;
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread__list { padding: 6px 14px 14px; }
|
||||||
|
|
||||||
|
.c { margin-top: 10px; }
|
||||||
|
|
||||||
|
/* The whole byline is the hit target for folding, so it wants to look like
|
||||||
|
one thing rather than a row of separate labels. */
|
||||||
|
.c__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
padding: 2px 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__head::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
.c__head::before {
|
||||||
|
content: "–";
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c:not([open]) > .c__head::before { content: "+"; }
|
||||||
|
|
||||||
|
.c__author { font-weight: 600; color: var(--ink); }
|
||||||
|
.c__hidden { font-variant-numeric: tabular-nums; }
|
||||||
|
.c[open] > .c__head .c__hidden { display: none; }
|
||||||
|
|
||||||
|
.c__mark {
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--accent-ink);
|
||||||
|
background: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__mark--op { background: #ff4500; }
|
||||||
|
.c__mark--mod, .c__mark--admin { background: #1a7f4b; }
|
||||||
|
|
||||||
|
.c__body { font-size: 15px; overflow-wrap: anywhere; }
|
||||||
|
.c__body > :first-child { margin-top: 4px; }
|
||||||
|
.c__body > :last-child { margin-bottom: 0; }
|
||||||
|
.c__body p { margin: 0 0 8px; }
|
||||||
|
.c__body a { color: inherit; text-decoration-color: var(--ink-dim); text-underline-offset: 2px; }
|
||||||
|
.c__body ul, .c__body ol { margin: 0 0 8px; padding-left: 20px; }
|
||||||
|
|
||||||
|
.c__body blockquote {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 3px solid var(--line);
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__body pre {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--ink) 6%, transparent);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__body code { font-size: 13px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.c__body pre code { font-size: 12px; }
|
||||||
|
|
||||||
|
/* The rule down the left is what tells you where a subtree ends, which is
|
||||||
|
the thing a wall of replies otherwise makes you count indents for. */
|
||||||
|
.c__replies {
|
||||||
|
margin: 6px 0 0 7px;
|
||||||
|
padding-left: 11px;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__more { margin: 8px 0 0; font-size: 12px; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
.thread__more {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.c__replies { margin-left: 4px; padding-left: 7px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- a quoted post ---------- */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A post inside a post. Bordered and inset rather than merely indented,
|
||||||
|
* because the whole job of this block is to be unmistakably someone else's
|
||||||
|
* — an indent alone reads as a continuation of the words above it.
|
||||||
|
*/
|
||||||
|
.quote {
|
||||||
|
margin: 0 14px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text + .quote, .media + .quote { margin-top: 14px; }
|
||||||
|
|
||||||
|
.quote__who {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px 6px;
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote__avatar {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote__name { font-weight: 600; }
|
||||||
|
.quote__handle, .quote__who time { color: var(--ink-dim); font-size: 12px; }
|
||||||
|
.quote__text { padding: 6px 12px 10px; font-size: 15px; overflow-wrap: anywhere; }
|
||||||
|
.quote__text a { color: inherit; text-decoration-color: var(--ink-dim); text-underline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Its own media sits flush in the block, the way the post's own does in the
|
||||||
|
card, so the two read as the same kind of thing at different sizes. */
|
||||||
|
.quote .media { border-bottom: 0; }
|
||||||
|
.quote__text + .media, .quote__who + .media { border-top: 1px solid var(--line); }
|
||||||
|
|
||||||
|
.quote__open {
|
||||||
|
display: block;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Deliberately smaller than the post's own media, and after the window-fitting
|
||||||
|
* rule above so it wins on source order at equal specificity. A quote is
|
||||||
|
* context for the post, and context that fills the screen has stopped being
|
||||||
|
* context.
|
||||||
|
*/
|
||||||
|
.post .quote .item img,
|
||||||
|
.post .quote .item video {
|
||||||
|
max-height: 40vh;
|
||||||
|
max-height: 40dvh;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A post that quotes another has two things to show, so its own media gives
|
||||||
|
* up the window-filling cap above. Left at full height the quote starts below
|
||||||
|
* the fold, which is the same as not having it — the whole reason it is here
|
||||||
|
* is that neither half reads without the other.
|
||||||
|
*/
|
||||||
|
.segment[data-quoted] .item img,
|
||||||
|
.segment[data-quoted] .item video {
|
||||||
|
max-height: 45vh;
|
||||||
|
max-height: 45dvh;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A picture someone put in a comment. Capped hard: it is a remark inside a
|
||||||
|
conversation, not the thing the page is about. */
|
||||||
|
.c__img {
|
||||||
|
display: block;
|
||||||
|
max-width: min(100%, 420px);
|
||||||
|
max-height: 40vh;
|
||||||
|
max-height: 40dvh;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--ink) 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A lone image is the whole comment more often than not, so it should not
|
||||||
|
carry a paragraph's worth of space above it as well as its own. */
|
||||||
|
.c__body > p:first-child > .c__img:first-child { margin-top: 2px; }
|
||||||
|
|||||||
+109
-4
@@ -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,10 +40,65 @@ function setupCopy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupMedia() {
|
function storedBrowser() {
|
||||||
const media = document.querySelector('.media');
|
try {
|
||||||
if (!media) return;
|
return localStorage.getItem(BROWSER_KEY) ?? 'default';
|
||||||
|
} catch {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The markup carries the plain address, which is the one thing the button
|
||||||
|
// must not offer: followed in the browser the rewrite rules are installed
|
||||||
|
// in, it redirects straight back to this page. So the link ships hidden and
|
||||||
|
// is only revealed once a browser has been picked to hand it to. With no
|
||||||
|
// choice made -- or none this system has a scheme for -- the copy button
|
||||||
|
// and the URL below it are the whole of it.
|
||||||
|
function applyBrowser(id) {
|
||||||
|
const os = detectOs();
|
||||||
|
|
||||||
|
for (const link of document.querySelectorAll('.original__open')) {
|
||||||
|
link.dataset.original ??= link.getAttribute('href');
|
||||||
|
|
||||||
|
const href = openUrlFor(link.dataset.original, id, os);
|
||||||
|
link.href = href;
|
||||||
|
link.hidden = href === link.dataset.original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only on the index page, and only with this file running: the choice is
|
||||||
|
// useless without the rewriting above, so the control ships hidden.
|
||||||
|
function setupBrowserPicker() {
|
||||||
|
const select = document.querySelector('.picker__select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
for (const browser of browsersFor(detectOs())) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = browser.id;
|
||||||
|
option.textContent = browser.label;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored id with no scheme on this system would leave the control
|
||||||
|
// showing nothing at all, so fall back rather than render a blank.
|
||||||
|
const stored = storedBrowser();
|
||||||
|
select.value = [...select.options].some((option) => option.value === stored) ? stored : 'default';
|
||||||
|
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(BROWSER_KEY, select.value);
|
||||||
|
} catch {
|
||||||
|
// Private browsing. The choice just won't survive the page.
|
||||||
|
}
|
||||||
|
applyBrowser(select.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
select.closest('.picker').hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One per rail: a post can carry several -- a thread of them, or a post and
|
||||||
|
// the post it quotes -- and wiring only the first leaves the rest inert.
|
||||||
|
function setupMedia(media) {
|
||||||
const rail = media.querySelector('.media__rail');
|
const rail = media.querySelector('.media__rail');
|
||||||
const items = [...media.querySelectorAll('.item')];
|
const items = [...media.querySelectorAll('.item')];
|
||||||
const at = media.querySelector('.media__at');
|
const at = media.querySelector('.media__at');
|
||||||
@@ -97,8 +155,55 @@ function setupMedia() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On iOS a video playing inline gets the "ambient" audio session, which the
|
||||||
|
// Ring/Silent switch mutes -- so the post plays perfectly and says nothing,
|
||||||
|
// and the only way to hear it is to go fullscreen. Claiming "playback" says
|
||||||
|
// what is true here: the sound is the point, not decoration. The session is
|
||||||
|
// not activated until something actually plays, so declaring it up front
|
||||||
|
// interrupts nothing; it is declared only on a page that has a video so an
|
||||||
|
// ordinary text post never claims it at all.
|
||||||
|
function setupAudioSession() {
|
||||||
|
if (!('audioSession' in navigator)) return;
|
||||||
|
if (!document.querySelector('video')) return;
|
||||||
|
navigator.audioSession.type = 'playback';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every comment is a <details open>, so folding one already works with this
|
||||||
|
// file missing. All this adds is doing the whole page at once, which is why
|
||||||
|
// the button ships hidden and is only revealed here.
|
||||||
|
function setupComments() {
|
||||||
|
const toggle = document.querySelector('.thread__toggle');
|
||||||
|
if (!toggle) return;
|
||||||
|
|
||||||
|
const all = [...document.querySelectorAll('details.c')];
|
||||||
|
if (all.length === 0) return;
|
||||||
|
toggle.hidden = false;
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const collapsing = toggle.dataset.collapsed !== 'true';
|
||||||
|
for (const comment of all) comment.open = !collapsing;
|
||||||
|
toggle.dataset.collapsed = String(collapsing);
|
||||||
|
toggle.textContent = collapsing ? 'Expand all' : 'Collapse all';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reopening one by hand means the page is no longer collapsed, so the
|
||||||
|
// button should offer the other thing.
|
||||||
|
for (const comment of all) {
|
||||||
|
comment.addEventListener('toggle', () => {
|
||||||
|
if (comment.open && toggle.dataset.collapsed === 'true') {
|
||||||
|
toggle.dataset.collapsed = 'false';
|
||||||
|
toggle.textContent = 'Collapse all';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setupCopy();
|
setupCopy();
|
||||||
setupMedia();
|
applyBrowser(storedBrowser());
|
||||||
|
setupBrowserPicker();
|
||||||
|
for (const media of document.querySelectorAll('.media')) setupMedia(media);
|
||||||
|
setupAudioSession();
|
||||||
|
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);
|
||||||
|
}
|
||||||
+117
-16
@@ -1,5 +1,5 @@
|
|||||||
import { config } from '../config.ts';
|
import { config } from '../config.ts';
|
||||||
import { ResolveError, type Media, type Post } from '../types.ts';
|
import { ResolveError, type Media, type Post, type Quoted, type Segment } from '../types.ts';
|
||||||
import { captureJson } from '../browser/capture.ts';
|
import { captureJson } from '../browser/capture.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';
|
||||||
@@ -8,6 +8,18 @@ const API = 'https://public.api.bsky.app/xrpc';
|
|||||||
|
|
||||||
type Aspect = { width: number; height: number };
|
type Aspect = { width: number; height: number };
|
||||||
|
|
||||||
|
type Actor = { handle?: string; displayName?: string; avatar?: string };
|
||||||
|
|
||||||
|
/** The quoted post as the API hands it over, when it is a post at all — a
|
||||||
|
* quote can also point at a feed, a list, or something deleted. */
|
||||||
|
type ViewRecord = {
|
||||||
|
$type?: string;
|
||||||
|
uri?: string;
|
||||||
|
author?: Actor;
|
||||||
|
value?: { text?: string; createdAt?: string };
|
||||||
|
embeds?: Embed[];
|
||||||
|
};
|
||||||
|
|
||||||
type Embed = {
|
type Embed = {
|
||||||
$type?: string;
|
$type?: string;
|
||||||
images?: Array<{ thumb?: string; fullsize?: string; alt?: string; aspectRatio?: Aspect }>;
|
images?: Array<{ thumb?: string; fullsize?: string; alt?: string; aspectRatio?: Aspect }>;
|
||||||
@@ -15,18 +27,26 @@ type Embed = {
|
|||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
aspectRatio?: Aspect;
|
aspectRatio?: Aspect;
|
||||||
media?: Embed;
|
media?: Embed;
|
||||||
record?: { embeds?: Embed[] };
|
/** A plain quote puts the record here; a quote with media of its own puts
|
||||||
|
* it one level further in. */
|
||||||
|
record?: ViewRecord & { record?: ViewRecord };
|
||||||
};
|
};
|
||||||
|
|
||||||
type BskyPost = {
|
type BskyPost = {
|
||||||
uri?: string;
|
uri?: string;
|
||||||
author?: { handle?: string; displayName?: string; avatar?: string };
|
author?: Actor;
|
||||||
record?: { text?: string; createdAt?: string };
|
record?: { text?: string; createdAt?: string };
|
||||||
embed?: Embed;
|
embed?: Embed;
|
||||||
indexedAt?: string;
|
indexedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThreadResponse = { thread?: { post?: BskyPost } };
|
type ThreadNode = {
|
||||||
|
post?: BskyPost;
|
||||||
|
parent?: ThreadNode;
|
||||||
|
replies?: ThreadNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThreadResponse = { thread?: ThreadNode };
|
||||||
|
|
||||||
/** `profile/<actor>/post/<rkey>` is the only shape that carries a post. */
|
/** `profile/<actor>/post/<rkey>` is the only shape that carries a post. */
|
||||||
function parsePath(pathRest: string): { actor: string; rkey: string } | undefined {
|
function parsePath(pathRest: string): { actor: string; rkey: string } | undefined {
|
||||||
@@ -67,15 +87,95 @@ export function mediaFromEmbed(embed: Embed | undefined): Media[] {
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
// A quote post carries the quoted post's media one level in. That is
|
// Nothing of its own. A quoted post's pictures belong to the quoted post
|
||||||
// usually the thing being pointed at, so it is what to show.
|
// and are shown with it, under the name of whoever actually posted them.
|
||||||
const quoted = embed.record?.embeds?.[0];
|
|
||||||
if (quoted) return mediaFromEmbed(quoted);
|
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toPost(post: BskyPost, originalUrl: string): Post {
|
/** `at://<did>/app.bsky.feed.post/<rkey>` is not a link anyone can open, so
|
||||||
|
* the web address is rebuilt from the handle and the record key. */
|
||||||
|
function webUrlFor(uri: string | undefined, handle: string | undefined): string | undefined {
|
||||||
|
const rkey = uri?.split('/').pop();
|
||||||
|
return rkey && handle ? `https://bsky.app/profile/${handle}/post/${rkey}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The post being quoted.
|
||||||
|
*
|
||||||
|
* A quote can also point at a feed, a list or a post that has since been
|
||||||
|
* deleted or blocked, and those arrive under different `$type`s in the same
|
||||||
|
* slot. Only a real post is taken; anything else is left out rather than
|
||||||
|
* rendered as a nameless empty box.
|
||||||
|
*/
|
||||||
|
export function quotedFrom(embed: Embed | undefined): Quoted | undefined {
|
||||||
|
// A quote carrying media of its own nests the record one level deeper.
|
||||||
|
const record = embed?.record?.record ?? embed?.record;
|
||||||
|
if (record?.$type !== 'app.bsky.embed.record#viewRecord' || !record.author) return undefined;
|
||||||
|
|
||||||
|
const url = webUrlFor(record.uri, record.author.handle);
|
||||||
|
return {
|
||||||
|
author: {
|
||||||
|
handle: `@${record.author.handle ?? 'unknown'}`,
|
||||||
|
...(record.author.displayName ? { displayName: record.author.displayName } : {}),
|
||||||
|
...(record.author.avatar ? { avatar: { url: record.author.avatar } } : {}),
|
||||||
|
},
|
||||||
|
...(record.value?.text ? { text: record.value.text } : {}),
|
||||||
|
media: mediaFromEmbed(record.embeds?.[0]),
|
||||||
|
...(record.value?.createdAt ? { postedAt: record.value.createdAt } : {}),
|
||||||
|
...(url ? { url } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentOf(post: BskyPost, isAnchor: boolean): Segment {
|
||||||
|
const quoted = quotedFrom(post.embed);
|
||||||
|
return {
|
||||||
|
...(post.record?.text ? { text: post.record.text } : {}),
|
||||||
|
media: mediaFromEmbed(post.embed),
|
||||||
|
...(post.record?.createdAt ? { postedAt: post.record.createdAt } : {}),
|
||||||
|
...(quoted ? { quoted } : {}),
|
||||||
|
...(isAnchor ? { isAnchor: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect the author's own chain around the linked post: their earlier posts
|
||||||
|
* leading up to it, and their own follow-ups after it.
|
||||||
|
*
|
||||||
|
* Everyone else's replies are left out. They are a conversation, not the
|
||||||
|
* thing that was shared, and on a busy post there are hundreds of them.
|
||||||
|
*/
|
||||||
|
export function selfThread(thread: ThreadNode): Segment[] {
|
||||||
|
const anchor = thread.post;
|
||||||
|
const handle = anchor?.author?.handle;
|
||||||
|
if (!anchor || !handle) return [];
|
||||||
|
|
||||||
|
const before: Segment[] = [];
|
||||||
|
for (let node = thread.parent; node?.post?.author?.handle === handle; node = node.parent) {
|
||||||
|
// Walking up, so each is older than the last.
|
||||||
|
before.unshift(segmentOf(node.post, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
const after: Segment[] = [];
|
||||||
|
let node: ThreadNode | undefined = thread;
|
||||||
|
const guard = new Set<string>();
|
||||||
|
for (;;) {
|
||||||
|
// A chain continues through the author's own reply. Where they left
|
||||||
|
// several, the earliest is the one the rest hangs off.
|
||||||
|
const next: ThreadNode | undefined = (node?.replies ?? [])
|
||||||
|
.filter((r) => r.post?.author?.handle === handle && r.post.uri && !guard.has(r.post.uri))
|
||||||
|
.sort((a, b) =>
|
||||||
|
(a.post?.record?.createdAt ?? '').localeCompare(b.post?.record?.createdAt ?? ''))[0];
|
||||||
|
if (!next?.post?.uri) break;
|
||||||
|
guard.add(next.post.uri);
|
||||||
|
after.push(segmentOf(next.post, false));
|
||||||
|
node = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...before, segmentOf(anchor, true), ...after];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPost(thread: ThreadNode, originalUrl: string): Post {
|
||||||
|
const post = thread.post ?? {};
|
||||||
const handle = post.author?.handle ?? 'unknown';
|
const handle = post.author?.handle ?? 'unknown';
|
||||||
return {
|
return {
|
||||||
platform: 'bluesky',
|
platform: 'bluesky',
|
||||||
@@ -86,10 +186,8 @@ export function toPost(post: BskyPost, originalUrl: string): Post {
|
|||||||
...(post.author?.displayName ? { displayName: post.author.displayName } : {}),
|
...(post.author?.displayName ? { displayName: post.author.displayName } : {}),
|
||||||
...(post.author?.avatar ? { avatar: { url: post.author.avatar } } : {}),
|
...(post.author?.avatar ? { avatar: { url: post.author.avatar } } : {}),
|
||||||
},
|
},
|
||||||
...(post.record?.text ? { text: post.record.text } : {}),
|
|
||||||
textPosition: 'above',
|
textPosition: 'above',
|
||||||
media: mediaFromEmbed(post.embed),
|
segments: selfThread(thread),
|
||||||
...(post.record?.createdAt ? { postedAt: post.record.createdAt } : {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,12 +210,15 @@ async function resolve({ page, originalUrl, pathRest }: ResolveContext): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uri = `at://${did}/app.bsky.feed.post/${parsed.rkey}`;
|
const uri = `at://${did}/app.bsky.feed.post/${parsed.rkey}`;
|
||||||
|
// Deep enough to follow a chain of any reasonable length. The cost is that
|
||||||
|
// the reply tree comes too -- a few hundred KB on a busy post -- and there
|
||||||
|
// is no way to ask for one author's branch.
|
||||||
const direct = await page.request
|
const direct = await page.request
|
||||||
.get(`${API}/app.bsky.feed.getPostThread?depth=0&parentHeight=0&uri=${encodeURIComponent(uri)}`)
|
.get(`${API}/app.bsky.feed.getPostThread?depth=12&parentHeight=12&uri=${encodeURIComponent(uri)}`)
|
||||||
.then((r) => (r.ok() ? (r.json() as Promise<ThreadResponse>) : undefined))
|
.then((r) => (r.ok() ? (r.json() as Promise<ThreadResponse>) : undefined))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
if (direct?.thread?.post) return toPost(direct.thread.post, originalUrl);
|
if (direct?.thread?.post) return toPost(direct.thread, originalUrl);
|
||||||
|
|
||||||
// Fall back to loading the post the way a person would and catching the
|
// Fall back to loading the post the way a person would and catching the
|
||||||
// same call as it goes past.
|
// same call as it goes past.
|
||||||
@@ -127,7 +228,7 @@ async function resolve({ page, originalUrl, pathRest }: ResolveContext): Promise
|
|||||||
const captured = captureJson<ThreadResponse>(page, (url) => url.includes('getPostThread?'));
|
const captured = captureJson<ThreadResponse>(page, (url) => url.includes('getPostThread?'));
|
||||||
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
|
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
|
||||||
const body = await captured.wait(config.navigationTimeoutMs);
|
const body = await captured.wait(config.navigationTimeoutMs);
|
||||||
if (body?.thread?.post) return toPost(body.thread.post, originalUrl);
|
if (body?.thread?.post) return toPost(body.thread, originalUrl);
|
||||||
|
|
||||||
throw new ResolveError('Bluesky returned no post for that URL', 'bluesky', originalUrl);
|
throw new ResolveError('Bluesky returned no post for that URL', 'bluesky', originalUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { threads } from './threads.ts';
|
|||||||
import { instagram } from './instagram.ts';
|
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';
|
||||||
|
|
||||||
export const platforms: readonly PlatformSpec[] = [x, threads, instagram, tiktok, bluesky];
|
export const platforms: readonly PlatformSpec[] = [x, threads, instagram, tiktok, bluesky, reddit];
|
||||||
|
|
||||||
const byPrefix = new Map(platforms.map((p) => [p.prefix, p]));
|
const byPrefix = new Map(platforms.map((p) => [p.prefix, p]));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ResolveError, 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, metaHeaders } from './meta-media.ts';
|
||||||
import { extractJsonObjectAfterKey } from './scan.ts';
|
import { extractJsonObjectAfterKey } from './scan.ts';
|
||||||
@@ -294,10 +294,12 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
|
|||||||
? { avatar: { url: scraped.avatar, fetchHeaders: metaHeaders(REFERER) } }
|
? { avatar: { url: scraped.avatar, fetchHeaders: metaHeaders(REFERER) } }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
...(scraped.text ? { text: scraped.text } : {}),
|
|
||||||
textPosition: 'below',
|
textPosition: 'below',
|
||||||
media: scraped.media,
|
segments: oneSegment({
|
||||||
...(scraped.postedAt ? { postedAt: scraped.postedAt } : {}),
|
...(scraped.text ? { text: scraped.text } : {}),
|
||||||
|
media: scraped.media,
|
||||||
|
...(scraped.postedAt ? { postedAt: scraped.postedAt } : {}),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import type { Page } from 'playwright';
|
||||||
|
import { ResolveError, oneSegment, type Comment, type Media, type Post } from '../types.ts';
|
||||||
|
import { readOpenGraph } from '../browser/capture.ts';
|
||||||
|
import { stripTracking } from './url.ts';
|
||||||
|
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||||
|
|
||||||
|
const HOST = 'https://www.reddit.com';
|
||||||
|
|
||||||
|
/** Path roots that are really Reddit paths, so anything else that arrives as
|
||||||
|
* a single opaque segment is a `redd.it` share code. */
|
||||||
|
const KNOWN_ROOTS = new Set(['r', 'u', 'user', 'comments', 'gallery', 'media', 'wiki']);
|
||||||
|
|
||||||
|
type Listing<T> = { kind?: string; data?: { children?: Array<Thing<T>> } };
|
||||||
|
type Thing<T> = { kind?: string; data?: T };
|
||||||
|
|
||||||
|
type RedditVideo = {
|
||||||
|
fallback_url?: string;
|
||||||
|
hls_url?: string;
|
||||||
|
has_audio?: boolean;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaMeta = {
|
||||||
|
status?: string;
|
||||||
|
e?: string;
|
||||||
|
m?: string;
|
||||||
|
s?: { u?: string; gif?: string; mp4?: string; x?: number; y?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Link = {
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
subreddit_name_prefixed?: string;
|
||||||
|
created_utc?: number;
|
||||||
|
selftext?: string;
|
||||||
|
permalink?: string;
|
||||||
|
url?: string;
|
||||||
|
url_overridden_by_dest?: string;
|
||||||
|
domain?: string;
|
||||||
|
post_hint?: string;
|
||||||
|
is_self?: boolean;
|
||||||
|
is_video?: boolean;
|
||||||
|
is_gallery?: boolean;
|
||||||
|
over_18?: boolean;
|
||||||
|
spoiler?: boolean;
|
||||||
|
link_flair_text?: string | null;
|
||||||
|
num_comments?: number;
|
||||||
|
score?: number;
|
||||||
|
gallery_data?: { items?: Array<{ media_id?: string }> };
|
||||||
|
media_metadata?: Record<string, MediaMeta>;
|
||||||
|
secure_media?: { reddit_video?: RedditVideo } | null;
|
||||||
|
media?: { reddit_video?: RedditVideo } | null;
|
||||||
|
preview?: {
|
||||||
|
images?: Array<{ source?: { url?: string; width?: number; height?: number } }>;
|
||||||
|
reddit_video_preview?: RedditVideo;
|
||||||
|
};
|
||||||
|
crosspost_parent_list?: Link[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommentData = {
|
||||||
|
author?: string;
|
||||||
|
body?: string;
|
||||||
|
media_metadata?: Record<string, MediaMeta>;
|
||||||
|
created_utc?: number;
|
||||||
|
score?: number;
|
||||||
|
score_hidden?: boolean;
|
||||||
|
is_submitter?: boolean;
|
||||||
|
distinguished?: string | null;
|
||||||
|
replies?: Listing<CommentData> | '';
|
||||||
|
};
|
||||||
|
|
||||||
|
type MoreData = { count?: number };
|
||||||
|
|
||||||
|
/** `[deleted]` and `[removed]` are the platform's own words, kept as they are. */
|
||||||
|
function authorName(author: string | undefined): string {
|
||||||
|
if (!author || author.startsWith('[')) return author ?? '[unknown]';
|
||||||
|
return `u/${author}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoFrom(seconds: number | undefined): string | undefined {
|
||||||
|
return seconds ? new Date(seconds * 1000).toISOString() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sized(width: number | undefined, height: number | undefined) {
|
||||||
|
return {
|
||||||
|
...(width ? { width } : {}),
|
||||||
|
...(height ? { height } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reddit's own video, which arrives in three forms at once.
|
||||||
|
*
|
||||||
|
* `fallback_url` is a plain MP4 and the nicer thing to serve — it proxies,
|
||||||
|
* so the viewer never talks to Reddit, and the native scrubber can seek it.
|
||||||
|
* But when a post has sound that MP4 is the video track alone: the audio is
|
||||||
|
* a separate file the DASH manifest stitches back on. So a silent post gets
|
||||||
|
* the MP4 and one with audio gets the HLS playlist, linked directly because
|
||||||
|
* proxying it would mean rewriting the manifest and every segment.
|
||||||
|
*/
|
||||||
|
function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[] {
|
||||||
|
const common = {
|
||||||
|
...sized(video.width, video.height),
|
||||||
|
...(video.duration ? { durationSec: video.duration } : {}),
|
||||||
|
// The still belongs to the video and not to the format it is served in.
|
||||||
|
// The HLS branch used to drop it, which is why a post with sound showed
|
||||||
|
// an empty box where every silent one showed a frame.
|
||||||
|
...(poster ? { poster: { url: poster } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (video.has_audio !== false && video.hls_url) {
|
||||||
|
return [{ kind: 'video', url: video.hls_url, hls: true, direct: true, ...common }];
|
||||||
|
}
|
||||||
|
const url = video.fallback_url ?? video.hls_url;
|
||||||
|
if (!url) return [];
|
||||||
|
if (url === video.hls_url) {
|
||||||
|
return [{ kind: 'video', url, hls: true, direct: true, ...common }];
|
||||||
|
}
|
||||||
|
return [{ kind: 'video', url, ...common }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry of a gallery post. Reddit keeps the pictures somewhere other
|
||||||
|
* than the order it wants them shown in, so the two have to be joined. */
|
||||||
|
function fromGalleryItem(meta: MediaMeta | undefined): Media[] {
|
||||||
|
if (!meta || meta.status !== 'valid') return [];
|
||||||
|
const size = sized(meta.s?.x, meta.s?.y);
|
||||||
|
|
||||||
|
// An animated entry ships as a video as well; that is smaller and seekable.
|
||||||
|
if (meta.s?.mp4) return [{ kind: 'video', url: meta.s.mp4, ...size }];
|
||||||
|
const url = meta.s?.gif ?? meta.s?.u;
|
||||||
|
return url ? [{ kind: 'image', url, ...size }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaFromLink(link: Link): Media[] {
|
||||||
|
// A crosspost is a wrapper with nothing of its own in it.
|
||||||
|
const crossposted = link.crosspost_parent_list?.[0];
|
||||||
|
if (crossposted && !link.is_self) {
|
||||||
|
const inner = mediaFromLink(crossposted);
|
||||||
|
if (inner.length) return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `scrubber_media_url` looks like a poster and is not one: it is a second,
|
||||||
|
// tiny MP4 for the timeline thumbnails. The still is in the preview.
|
||||||
|
const still = link.preview?.images?.[0]?.source?.url;
|
||||||
|
|
||||||
|
const video = link.secure_media?.reddit_video ?? link.media?.reddit_video;
|
||||||
|
if (video) return fromRedditVideo(video, still);
|
||||||
|
|
||||||
|
if (link.is_gallery) {
|
||||||
|
const items = link.gallery_data?.items ?? [];
|
||||||
|
const gallery = items.flatMap((item) =>
|
||||||
|
fromGalleryItem(item.media_id ? link.media_metadata?.[item.media_id] : undefined));
|
||||||
|
if (gallery.length) return gallery;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A GIF link is served as a video by the preview, which is the version
|
||||||
|
// worth having: the original is often tens of megabytes.
|
||||||
|
const asVideo = link.preview?.reddit_video_preview;
|
||||||
|
if (asVideo) return fromRedditVideo(asVideo, still);
|
||||||
|
|
||||||
|
const direct = link.url_overridden_by_dest ?? link.url;
|
||||||
|
if (direct && /\.(jpe?g|png|webp|gif)(\?|$)/i.test(direct)) {
|
||||||
|
const source = link.preview?.images?.[0]?.source;
|
||||||
|
return [{ kind: 'image', url: direct, ...sized(source?.width, source?.height) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link to somewhere else: the preview is all Reddit has, and it is still
|
||||||
|
// worth showing above the link itself.
|
||||||
|
const preview = link.preview?.images?.[0]?.source;
|
||||||
|
if (preview?.url) {
|
||||||
|
return [{ kind: 'image', url: preview.url, ...sized(preview.width, preview.height) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The words of the post: the self text, or — for a link post — the
|
||||||
|
* destination, which is the whole content of the post and needs to be
|
||||||
|
* tappable.
|
||||||
|
*/
|
||||||
|
function bodyOf(link: Link): string | undefined {
|
||||||
|
if (link.selftext?.trim()) return link.selftext;
|
||||||
|
const dest = link.url_overridden_by_dest;
|
||||||
|
if (dest && !dest.includes('redd.it') && !dest.startsWith(`${HOST}/gallery/`)) return dest;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole of ``, with the target captured. */
|
||||||
|
const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Giphy token, which unlike the others says what it points at.
|
||||||
|
*
|
||||||
|
* Reddit writes a variant name after the id on some of them
|
||||||
|
* (`giphy|abc123|downsized`); only the id is kept, because Giphy does not
|
||||||
|
* serve every variant for every gif but always serves the full one.
|
||||||
|
*/
|
||||||
|
const GIPHY_TOKEN = /^giphy\|([A-Za-z0-9]+)(?:\|[a-z_]+)?$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point a comment's inline images at something fetchable.
|
||||||
|
*
|
||||||
|
* Reddit writes them as ``, ``
|
||||||
|
* or `` — a token rather than an address. Usually the
|
||||||
|
* token is a key in that same comment's `media_metadata`, which is where the
|
||||||
|
* real URL is, so one lookup covers all three and none of them needs naming
|
||||||
|
* here.
|
||||||
|
*
|
||||||
|
* Usually, not always: Reddit ships plenty of comments carrying a Giphy token
|
||||||
|
* and no `media_metadata` at all, and with nothing to look the token up in
|
||||||
|
* those showed the token itself where the gif should have been. Giphy is the
|
||||||
|
* one kind that can be resolved without the lookup, the id in it being Giphy's
|
||||||
|
* own, so it falls back to Giphy's address for that id. The other two cannot:
|
||||||
|
* their ids mean nothing off Reddit.
|
||||||
|
*
|
||||||
|
* A target that is already an address is not a key, so it falls through
|
||||||
|
* untouched.
|
||||||
|
*/
|
||||||
|
export function resolveInlineImages(
|
||||||
|
body: string,
|
||||||
|
meta: Record<string, MediaMeta> | undefined,
|
||||||
|
): string {
|
||||||
|
return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => {
|
||||||
|
const entry = meta?.[token];
|
||||||
|
if (entry?.status === 'valid') {
|
||||||
|
// An animated one has both; the GIF plays in an `<img>` on its own,
|
||||||
|
// which an MP4 does not.
|
||||||
|
const url = entry.s?.gif ?? entry.s?.u;
|
||||||
|
if (url) return ``;
|
||||||
|
}
|
||||||
|
|
||||||
|
const giphy = GIPHY_TOKEN.exec(token)?.[1];
|
||||||
|
return giphy ? `` : whole;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commentsFrom(listing: Listing<CommentData> | undefined): {
|
||||||
|
comments: Comment[];
|
||||||
|
more: number;
|
||||||
|
} {
|
||||||
|
const comments: Comment[] = [];
|
||||||
|
let more = 0;
|
||||||
|
|
||||||
|
for (const child of listing?.data?.children ?? []) {
|
||||||
|
if (child.kind === 'more') {
|
||||||
|
more += (child.data as MoreData | undefined)?.count ?? 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const data = child.data;
|
||||||
|
if (!data || data.body === undefined) continue;
|
||||||
|
|
||||||
|
const replies = data.replies && typeof data.replies === 'object'
|
||||||
|
? commentsFrom(data.replies)
|
||||||
|
: { comments: [], more: 0 };
|
||||||
|
|
||||||
|
comments.push({
|
||||||
|
author: authorName(data.author),
|
||||||
|
...(data.body ? { text: resolveInlineImages(data.body, data.media_metadata) } : {}),
|
||||||
|
...(isoFrom(data.created_utc) ? { postedAt: isoFrom(data.created_utc) } : {}),
|
||||||
|
// Reddit hides the score on a new comment so an early downvote cannot
|
||||||
|
// steer the rest. Showing a placeholder 1 would be a lie.
|
||||||
|
...(data.score_hidden !== true && typeof data.score === 'number' ? { score: data.score } : {}),
|
||||||
|
...(data.is_submitter ? { isAuthor: true } : {}),
|
||||||
|
...(data.distinguished ? { distinguished: data.distinguished } : {}),
|
||||||
|
replies: replies.comments,
|
||||||
|
...(replies.more ? { moreReplies: replies.more } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { comments, more };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPost(body: [Listing<Link>, Listing<CommentData>], originalUrl: string): Post {
|
||||||
|
const link = body[0]?.data?.children?.[0]?.data;
|
||||||
|
if (!link) throw new ResolveError('Reddit returned no post', 'reddit', originalUrl);
|
||||||
|
|
||||||
|
const { comments, more } = commentsFrom(body[1]);
|
||||||
|
const flags = [
|
||||||
|
link.link_flair_text?.trim(),
|
||||||
|
link.over_18 ? 'NSFW' : undefined,
|
||||||
|
link.spoiler ? 'Spoiler' : undefined,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: 'reddit',
|
||||||
|
platformLabel: 'Reddit',
|
||||||
|
originalUrl,
|
||||||
|
author: {
|
||||||
|
// The subreddit is the identity a Reddit link carries — the poster is
|
||||||
|
// a detail underneath it, so it goes in the display name.
|
||||||
|
handle: link.subreddit_name_prefixed ?? 'reddit',
|
||||||
|
displayName: [authorName(link.author), ...flags].join(' · '),
|
||||||
|
},
|
||||||
|
textPosition: 'above',
|
||||||
|
segments: oneSegment({
|
||||||
|
...(link.title ? { title: link.title } : {}),
|
||||||
|
...(bodyOf(link) ? { text: bodyOf(link) } : {}),
|
||||||
|
media: mediaFromLink(link),
|
||||||
|
...(isoFrom(link.created_utc) ? { postedAt: isoFrom(link.created_utc) } : {}),
|
||||||
|
}),
|
||||||
|
comments,
|
||||||
|
...(more ? { moreComments: more } : {}),
|
||||||
|
...(typeof link.num_comments === 'number' ? { commentCount: link.num_comments } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlatComment = {
|
||||||
|
depth: number;
|
||||||
|
author: string;
|
||||||
|
score: number;
|
||||||
|
created: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the nesting from a flat list of comments that each know only their
|
||||||
|
* own depth, which is how the rendered page presents them.
|
||||||
|
*/
|
||||||
|
export function treeFromDepths(flat: FlatComment[]): Comment[] {
|
||||||
|
const roots: Comment[] = [];
|
||||||
|
// The comment last seen at each depth, so a reply can be attached to
|
||||||
|
// whatever it appeared underneath.
|
||||||
|
const openAt: Comment[] = [];
|
||||||
|
|
||||||
|
for (const row of flat) {
|
||||||
|
if (!row.text) continue;
|
||||||
|
const comment: Comment = {
|
||||||
|
author: authorName(row.author),
|
||||||
|
text: row.text,
|
||||||
|
...(row.created && !Number.isNaN(Date.parse(row.created))
|
||||||
|
? { postedAt: new Date(row.created).toISOString() }
|
||||||
|
: {}),
|
||||||
|
...(Number.isFinite(row.score) ? { score: row.score } : {}),
|
||||||
|
replies: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const parent = openAt[row.depth - 1];
|
||||||
|
if (row.depth > 0 && parent) parent.replies.push(comment);
|
||||||
|
else roots.push(comment);
|
||||||
|
// Anything deeper belonged to the comment this one just replaced.
|
||||||
|
openAt.length = row.depth + 1;
|
||||||
|
openAt[row.depth] = comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the comment tree back out of the rendered page.
|
||||||
|
*
|
||||||
|
* The floor, for when the JSON is refused. Reddit renders every comment as a
|
||||||
|
* `<shreddit-comment>` carrying its own depth, flat in document order.
|
||||||
|
*/
|
||||||
|
async function commentsFromDom(page: Page): Promise<Comment[]> {
|
||||||
|
const flat = await page
|
||||||
|
.evaluate(() =>
|
||||||
|
[...document.querySelectorAll('shreddit-comment')].map((el) => ({
|
||||||
|
depth: Number(el.getAttribute('depth') ?? 0),
|
||||||
|
author: el.getAttribute('author') ?? '',
|
||||||
|
score: Number(el.getAttribute('score') ?? Number.NaN),
|
||||||
|
created: el.getAttribute('created') ?? '',
|
||||||
|
text: el.querySelector('[slot="comment"]')?.textContent?.trim() ?? '',
|
||||||
|
})))
|
||||||
|
.catch((): FlatComment[] => []);
|
||||||
|
|
||||||
|
return treeFromDepths(flat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whatever the page will admit to, when the JSON will not come. */
|
||||||
|
async function fromPage(page: Page, originalUrl: string): Promise<Post> {
|
||||||
|
const attrs = await page
|
||||||
|
.evaluate(() => {
|
||||||
|
const post = document.querySelector('shreddit-post');
|
||||||
|
if (!post) return null;
|
||||||
|
const get = (name: string): string | undefined => post.getAttribute(name) ?? undefined;
|
||||||
|
return {
|
||||||
|
title: get('post-title'),
|
||||||
|
author: get('author'),
|
||||||
|
subreddit: get('subreddit-prefixed-name'),
|
||||||
|
created: get('created-timestamp'),
|
||||||
|
score: get('score'),
|
||||||
|
comments: get('comment-count'),
|
||||||
|
image: get('content-href'),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
const og = await readOpenGraph(page);
|
||||||
|
const title = attrs?.title ?? og['og:title'];
|
||||||
|
if (!title) throw new ResolveError('Reddit served neither the post nor its data', 'reddit', originalUrl);
|
||||||
|
|
||||||
|
const image = attrs?.image ?? og['og:image'];
|
||||||
|
const media: Media[] =
|
||||||
|
image && /\.(jpe?g|png|webp|gif)(\?|$)/i.test(image) ? [{ kind: 'image', url: image }] : [];
|
||||||
|
|
||||||
|
const count = Number(attrs?.comments);
|
||||||
|
return {
|
||||||
|
platform: 'reddit',
|
||||||
|
platformLabel: 'Reddit',
|
||||||
|
originalUrl,
|
||||||
|
author: {
|
||||||
|
handle: attrs?.subreddit ?? 'reddit',
|
||||||
|
displayName: authorName(attrs?.author),
|
||||||
|
},
|
||||||
|
textPosition: 'above',
|
||||||
|
segments: oneSegment({
|
||||||
|
title,
|
||||||
|
media,
|
||||||
|
...(attrs?.created && !Number.isNaN(Date.parse(attrs.created))
|
||||||
|
? { postedAt: new Date(attrs.created).toISOString() }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
comments: await commentsFromDom(page),
|
||||||
|
...(Number.isFinite(count) ? { commentCount: count } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `.json` twin of a post URL, asked for at the size of one page. */
|
||||||
|
function jsonUrlFor(postUrl: string): string {
|
||||||
|
const url = new URL(postUrl);
|
||||||
|
url.pathname = `${url.pathname.replace(/\/+$/, '')}/.json`;
|
||||||
|
url.search = '';
|
||||||
|
url.searchParams.set('raw_json', '1');
|
||||||
|
url.searchParams.set('limit', '100');
|
||||||
|
url.searchParams.set('sort', 'confidence');
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShareLink(url: URL): boolean {
|
||||||
|
return url.hostname === 'redd.it' || /^\/r\/[^/]+\/s\/[^/]+/.test(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share links from the app are opaque, and the post id is only on the other
|
||||||
|
* side of a redirect. One request that is told not to follow it is enough —
|
||||||
|
* far cheaper than loading the page to find out where it went.
|
||||||
|
*/
|
||||||
|
async function followShareLink(page: Page, originalUrl: string): Promise<string> {
|
||||||
|
const response = await page.request
|
||||||
|
.get(originalUrl, { maxRedirects: 0 })
|
||||||
|
.catch(() => undefined);
|
||||||
|
const location = response?.headers()['location'];
|
||||||
|
if (!location) return originalUrl;
|
||||||
|
try {
|
||||||
|
return stripTracking(new URL(location, originalUrl)).href;
|
||||||
|
} catch {
|
||||||
|
return originalUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(page: Page, url: string): Promise<[Listing<Link>, Listing<CommentData>] | undefined> {
|
||||||
|
const response = await page.request
|
||||||
|
.get(url, { headers: { Accept: 'application/json' } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
if (!response?.ok()) return undefined;
|
||||||
|
if (!response.headers()['content-type']?.includes('json')) return undefined;
|
||||||
|
const body = (await response.json().catch(() => undefined)) as unknown;
|
||||||
|
return Array.isArray(body) && body.length >= 2 ? (body as [Listing<Link>, Listing<CommentData>]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve({ page, originalUrl }: ResolveContext): Promise<Post> {
|
||||||
|
let postUrl = originalUrl;
|
||||||
|
try {
|
||||||
|
if (isShareLink(new URL(originalUrl))) postUrl = await followShareLink(page, originalUrl);
|
||||||
|
} catch {
|
||||||
|
// Not a URL we can take apart; the request below will say so.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reddit's JSON is the whole post and the whole first page of comments in
|
||||||
|
// one response, which no amount of reading the page can match.
|
||||||
|
let body = await fetchJson(page, jsonUrlFor(postUrl));
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
// A cold context gets a JavaScript challenge instead, which the page
|
||||||
|
// solves by itself on any ordinary navigation. Doing that once leaves the
|
||||||
|
// cookie behind and the JSON answers from then on, including for every
|
||||||
|
// later post.
|
||||||
|
await page.goto(postUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
body = await fetchJson(page, jsonUrlFor(page.url() || postUrl));
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
// Still refused. The page in front of us is the post, so read that.
|
||||||
|
await page.waitForSelector('shreddit-comment', { timeout: 5_000 }).catch(() => undefined);
|
||||||
|
return fromPage(page, originalUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resolved permalink, not the share code we came in with: an opaque
|
||||||
|
// `/s/` link says nothing about what it points at, and handing it back is
|
||||||
|
// the same as handing back a tracking parameter.
|
||||||
|
return toPost(body, postUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reddit: PlatformSpec = {
|
||||||
|
id: 'reddit',
|
||||||
|
label: 'Reddit',
|
||||||
|
prefix: 'reddit',
|
||||||
|
canonicalHost: 'www.reddit.com',
|
||||||
|
textPosition: 'above',
|
||||||
|
matchesHost: (host) => host === 'reddit.com' || host.endsWith('.reddit.com') || host === 'redd.it',
|
||||||
|
/** A single opaque segment is a `redd.it` share code — the rewrite rule
|
||||||
|
* drops the host, so `redd.it/abc123` arrives as just `abc123`. */
|
||||||
|
buildOriginalUrl: (pathRest, search) => {
|
||||||
|
const first = pathRest.split('/')[0] ?? '';
|
||||||
|
if (first && !pathRest.includes('/') && !KNOWN_ROOTS.has(first)) {
|
||||||
|
return `https://redd.it/${first}${search}`;
|
||||||
|
}
|
||||||
|
return `${HOST}/${pathRest}${search}`;
|
||||||
|
},
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
+141
-34
@@ -1,18 +1,36 @@
|
|||||||
import { ResolveError, type Media, type Post } from '../types.ts';
|
import { ResolveError, type Media, type Post, type Segment } 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, metaHeaders, 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';
|
||||||
|
|
||||||
const REFERER = 'https://www.threads.com/';
|
const REFERER = 'https://www.threads.com/';
|
||||||
|
|
||||||
|
/** One post as Threads ships it, alongside the media fields Instagram shares. */
|
||||||
|
type ThreadsPost = MetaMediaNode & {
|
||||||
|
code?: string;
|
||||||
|
taken_at?: number;
|
||||||
|
caption?: { text?: string };
|
||||||
|
user?: { username?: string };
|
||||||
|
text_post_app_info?: {
|
||||||
|
reply_to_author?: { username?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThreadContainer = {
|
||||||
|
thread_items?: Array<{ post?: ThreadsPost }>;
|
||||||
|
};
|
||||||
|
|
||||||
function handleFromPath(pathRest: string): string | undefined {
|
function handleFromPath(pathRest: string): string | undefined {
|
||||||
const match = /^(@[^/]+)/.exec(pathRest);
|
return /^(@[^/]+)/.exec(pathRest)?.[1];
|
||||||
return match?.[1];
|
}
|
||||||
|
|
||||||
|
function codeFromPath(pathRest: string): string | undefined {
|
||||||
|
return /\/post\/([A-Za-z0-9_-]+)/.exec(`/${pathRest}`)?.[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Threads ships its Relay payloads in `<script type="application/json">`
|
/** Threads ships its Relay payloads in `<script type="application/json">`
|
||||||
* tags, several dozen of them, in an order that is not worth relying on. */
|
* tags, several dozen of them, in an order not worth relying on. */
|
||||||
async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> {
|
async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> {
|
||||||
const raw = await ctx.page
|
const raw = await ctx.page
|
||||||
.evaluate(() =>
|
.evaluate(() =>
|
||||||
@@ -31,60 +49,149 @@ async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fromOpenGraph(ctx: ResolveContext): Promise<{ media: Media[]; text?: string }> {
|
/** Every post the page shipped, from the thread containers it renders. */
|
||||||
const og = await readOpenGraph(ctx.page);
|
export function collectPosts(root: unknown): ThreadsPost[] {
|
||||||
const fetchHeaders = metaHeaders(REFERER);
|
const seen = new Set<unknown>();
|
||||||
|
const queue: unknown[] = [root];
|
||||||
|
const posts: ThreadsPost[] = [];
|
||||||
|
|
||||||
if (og['og:video']) {
|
for (let i = 0; i < queue.length; i += 1) {
|
||||||
return {
|
const value = queue[i];
|
||||||
media: [{
|
if (value === null || typeof value !== 'object' || seen.has(value)) continue;
|
||||||
kind: 'video',
|
seen.add(value);
|
||||||
url: og['og:video'],
|
|
||||||
fetchHeaders,
|
if (Array.isArray(value)) {
|
||||||
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}),
|
queue.push(...value);
|
||||||
}],
|
continue;
|
||||||
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
}
|
||||||
};
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const items = (record as ThreadContainer).thread_items;
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item?.post?.code) posts.push(item.post);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue.push(...Object.values(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The author's own chain, and nothing else.
|
||||||
|
*
|
||||||
|
* The page carries three kinds of post: the linked one, the author's
|
||||||
|
* follow-ups, and a pile of other people's replies and recommendations. A
|
||||||
|
* follow-up is the author replying to themselves, which is exactly what
|
||||||
|
* `reply_to_author` distinguishes — someone else replying to the same post
|
||||||
|
* carries the same field with a different name on it.
|
||||||
|
*/
|
||||||
|
export function selfThread(posts: ThreadsPost[], anchorCode: string | undefined): ThreadsPost[] {
|
||||||
|
const anchor = anchorCode ? posts.find((p) => p.code === anchorCode) : undefined;
|
||||||
|
const author = anchor?.user?.username;
|
||||||
|
if (!anchor || !author) return [];
|
||||||
|
|
||||||
|
// Same author, oldest first. Everyone else is a reply or a recommendation.
|
||||||
|
const mine = [...new Map(
|
||||||
|
posts.filter((p) => p.user?.username === author && p.code).map((p) => [p.code, p]),
|
||||||
|
).values()].sort((a, b) => (a.taken_at ?? 0) - (b.taken_at ?? 0));
|
||||||
|
|
||||||
|
const at = mine.findIndex((p) => p.code === anchor.code);
|
||||||
|
if (at === -1) return [anchor];
|
||||||
|
|
||||||
|
// A post continues the one before it when it is the author replying to
|
||||||
|
// themselves. Walk out from the anchor in both directions for as long as
|
||||||
|
// that holds: the link may point at the middle of a thread, and the first
|
||||||
|
// post of a thread is not a reply to anything, so it can only be reached
|
||||||
|
// backwards from the one that answers it.
|
||||||
|
const continues = (post: ThreadsPost | undefined): boolean =>
|
||||||
|
post?.text_post_app_info?.reply_to_author?.username === author;
|
||||||
|
|
||||||
|
let first = at;
|
||||||
|
while (first > 0 && continues(mine[first])) first -= 1;
|
||||||
|
|
||||||
|
let last = at;
|
||||||
|
while (last + 1 < mine.length && continues(mine[last + 1])) last += 1;
|
||||||
|
|
||||||
|
return mine.slice(first, last + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentOf(post: ThreadsPost, isAnchor: boolean): Segment {
|
||||||
return {
|
return {
|
||||||
media: og['og:image'] ? [{ kind: 'image', url: og['og:image'], fetchHeaders }] : [],
|
...(post.caption?.text ? { text: post.caption.text } : {}),
|
||||||
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
media: mediaFromMetaNode(post, REFERER),
|
||||||
|
...(post.taken_at ? { postedAt: new Date(post.taken_at * 1000).toISOString() } : {}),
|
||||||
|
...(isAnchor ? { isAnchor: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The floor, when the structured payload is not there to be read. */
|
||||||
|
async function fromOpenGraph(ctx: ResolveContext): Promise<Segment | undefined> {
|
||||||
|
const og = await readOpenGraph(ctx.page);
|
||||||
|
const fetchHeaders = metaHeaders(REFERER);
|
||||||
|
const media: Media[] = [];
|
||||||
|
|
||||||
|
if (og['og:video']) {
|
||||||
|
media.push({
|
||||||
|
kind: 'video',
|
||||||
|
url: og['og:video'],
|
||||||
|
fetchHeaders,
|
||||||
|
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}),
|
||||||
|
});
|
||||||
|
} else if (og['og:image']) {
|
||||||
|
media.push({ kind: 'image', url: og['og:image'], fetchHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = og['og:description'];
|
||||||
|
if (media.length === 0 && !text) return undefined;
|
||||||
|
return { media, ...(text ? { text } : {}), isAnchor: true };
|
||||||
|
}
|
||||||
|
|
||||||
async function resolve(ctx: ResolveContext): Promise<Post> {
|
async function resolve(ctx: ResolveContext): Promise<Post> {
|
||||||
const { page, originalUrl, pathRest } = ctx;
|
const { page, originalUrl, pathRest } = ctx;
|
||||||
|
|
||||||
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
|
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
const payloads = await inlinePayloads(ctx);
|
const payloads = await inlinePayloads(ctx);
|
||||||
const node = findMetaMedia(payloads);
|
const chain = selfThread(collectPosts(payloads), codeFromPath(pathRest));
|
||||||
|
|
||||||
let media = node ? mediaFromMetaNode(node, REFERER) : [];
|
let segments: Segment[] = chain.map((post) =>
|
||||||
let text = findCaption(payloads);
|
segmentOf(post, post.code === codeFromPath(pathRest)),
|
||||||
|
);
|
||||||
|
|
||||||
if (media.length === 0 || !text) {
|
if (segments.length === 0) {
|
||||||
const og = await fromOpenGraph(ctx);
|
// No thread containers. Fall back to whatever single post is readable.
|
||||||
if (media.length === 0) media = og.media;
|
const node = findMetaMedia(payloads);
|
||||||
text ??= og.text;
|
const caption = findCaption(payloads);
|
||||||
|
if (node ?? caption) {
|
||||||
|
segments = [{
|
||||||
|
media: node ? mediaFromMetaNode(node, REFERER) : [],
|
||||||
|
...(caption ? { text: caption } : {}),
|
||||||
|
isAnchor: true,
|
||||||
|
}];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Threads post is often only words, so unlike the others an empty media
|
if (segments.length === 0) {
|
||||||
// list is a perfectly good result — but nothing at all is a failure.
|
const og = await fromOpenGraph(ctx);
|
||||||
if (media.length === 0 && !text) {
|
if (og) segments = [og];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Threads post is often only words, so empty media is a fine result --
|
||||||
|
// but nothing at all is a failure.
|
||||||
|
if (segments.length === 0) {
|
||||||
throw new ResolveError('Threads returned nothing for that URL', 'threads', originalUrl);
|
throw new ResolveError('Threads returned nothing for that URL', 'threads', originalUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handle = handleFromPath(pathRest) ?? '@threads';
|
const handle = chain[0]?.user?.username ?? handleFromPath(pathRest)?.slice(1) ?? 'threads';
|
||||||
return {
|
return {
|
||||||
platform: 'threads',
|
platform: 'threads',
|
||||||
platformLabel: 'Threads',
|
platformLabel: 'Threads',
|
||||||
originalUrl,
|
originalUrl,
|
||||||
author: { handle },
|
author: { handle: `@${handle}` },
|
||||||
...(text ? { text } : {}),
|
|
||||||
textPosition: 'above',
|
textPosition: 'above',
|
||||||
media,
|
segments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-12
@@ -1,5 +1,5 @@
|
|||||||
import type { Page } from 'playwright';
|
import type { Page } from 'playwright';
|
||||||
import { ChallengeError, ResolveError, type Media, type Post } from '../types.ts';
|
import { ChallengeError, ResolveError, oneSegment, type Media, type Post } from '../types.ts';
|
||||||
import { cookieHeaderFor } from '../browser/pool.ts';
|
import { cookieHeaderFor } from '../browser/pool.ts';
|
||||||
import { CHALLENGE_SELECTORS, park } from '../challenge/registry.ts';
|
import { CHALLENGE_SELECTORS, park } from '../challenge/registry.ts';
|
||||||
import { config } from '../config.ts';
|
import { config } from '../config.ts';
|
||||||
@@ -157,12 +157,14 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
|
|||||||
...(item.author?.nickname ? { displayName: item.author.nickname } : {}),
|
...(item.author?.nickname ? { displayName: item.author.nickname } : {}),
|
||||||
...(avatar ? { avatar: { url: avatar, fetchHeaders: await tiktokHeaders(avatar) } } : {}),
|
...(avatar ? { avatar: { url: avatar, fetchHeaders: await tiktokHeaders(avatar) } } : {}),
|
||||||
},
|
},
|
||||||
...(item.desc ? { text: item.desc } : {}),
|
|
||||||
textPosition: 'below',
|
textPosition: 'below',
|
||||||
media,
|
segments: oneSegment({
|
||||||
...(Number.isFinite(created) && created > 0
|
...(item.desc ? { text: item.desc } : {}),
|
||||||
? { postedAt: new Date(created * 1000).toISOString() }
|
media,
|
||||||
: {}),
|
...(Number.isFinite(created) && created > 0
|
||||||
|
? { postedAt: new Date(created * 1000).toISOString() }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,13 +192,15 @@ async function resolve(ctx: ResolveContext): Promise<Post> {
|
|||||||
platformLabel: 'TikTok',
|
platformLabel: 'TikTok',
|
||||||
originalUrl,
|
originalUrl,
|
||||||
author: { handle: handleFrom(landed) ?? handleFrom(originalUrl) ?? '@tiktok' },
|
author: { handle: handleFrom(landed) ?? handleFrom(originalUrl) ?? '@tiktok' },
|
||||||
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
|
||||||
textPosition: 'below',
|
textPosition: 'below',
|
||||||
media: [{
|
segments: oneSegment({
|
||||||
kind: og['og:video'] ? 'video' : 'image',
|
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
||||||
url,
|
media: [{
|
||||||
fetchHeaders: await tiktokHeaders(url),
|
kind: og['og:video'] ? 'video' : 'image',
|
||||||
}],
|
url,
|
||||||
|
fetchHeaders: await tiktokHeaders(url),
|
||||||
|
}],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const TRACKING_PARAMS = new Set([
|
|||||||
'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',
|
||||||
'share_app_id', 'share_link_id', 'share_item_id', 'tt_from',
|
'share_app_id', 'share_link_id', 'share_item_id', 'tt_from',
|
||||||
|
'share_id', 'rdt', 'correlation_id', 'post_fullname', 'ref_campaign',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function stripTracking(url: URL): URL {
|
export function stripTracking(url: URL): URL {
|
||||||
|
|||||||
+122
-18
@@ -1,5 +1,5 @@
|
|||||||
import { config } from '../config.ts';
|
import { config } from '../config.ts';
|
||||||
import { ResolveError, type Media, type Post } from '../types.ts';
|
import { ResolveError, oneSegment, type Author, type Media, type Post, type Quoted } from '../types.ts';
|
||||||
import { captureJson } from '../browser/capture.ts';
|
import { captureJson } from '../browser/capture.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';
|
||||||
@@ -16,15 +16,89 @@ type MediaDetail = {
|
|||||||
video_info?: { duration_millis?: number; variants?: VideoVariant[] };
|
video_info?: { duration_millis?: number; variants?: VideoVariant[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TweetUser = { screen_name?: string; name?: string; profile_image_url_https?: string };
|
||||||
|
|
||||||
type TweetResult = {
|
type TweetResult = {
|
||||||
|
id_str?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
/** Which slice of `text` X itself shows. UTF-16 indices. */
|
||||||
|
display_text_range?: [number, number];
|
||||||
|
entities?: { urls?: Array<{ url?: string; expanded_url?: string }> };
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
user?: { screen_name?: string; name?: string; profile_image_url_https?: string };
|
user?: TweetUser;
|
||||||
mediaDetails?: MediaDetail[];
|
mediaDetails?: MediaDetail[];
|
||||||
quoted_tweet?: TweetResult;
|
quoted_tweet?: TweetResult;
|
||||||
tombstone?: unknown;
|
tombstone?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X hands back post text with the markup-significant characters already
|
||||||
|
* escaped — an ampersand someone typed arrives as `&`. Everything
|
||||||
|
* downstream escapes on the way out, so leaving them would put the entity
|
||||||
|
* itself on screen.
|
||||||
|
*/
|
||||||
|
const ENTITIES: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeEntities(text: string): string {
|
||||||
|
// `&` last would double-decode `&lt;`; one pass over the lot cannot.
|
||||||
|
return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => ENTITIES[entity] ?? entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHORTLINK = /https?:\/\/t\.co\/[A-Za-z0-9]+/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put back the addresses `t.co` stands in for.
|
||||||
|
*
|
||||||
|
* Every link in a post is rewritten to a shortlink before it is stored, so
|
||||||
|
* left alone the page shows `t.co/QdJhOVu4En` — which says nothing about
|
||||||
|
* where it goes, and routes the reader through X's click tracker to find
|
||||||
|
* out. The payload carries the real address alongside, so there is no reason
|
||||||
|
* to show the stand-in.
|
||||||
|
*
|
||||||
|
* Matched on the shortlink itself rather than by `indices`, which are offsets
|
||||||
|
* into a string this function is in the middle of changing the length of.
|
||||||
|
*/
|
||||||
|
function expandShortlinks(text: string, result: TweetResult): string {
|
||||||
|
const expansions = new Map<string, string>();
|
||||||
|
for (const entity of result.entities?.urls ?? []) {
|
||||||
|
if (!entity.url || !entity.expanded_url) continue;
|
||||||
|
if (!/^https?:\/\//.test(entity.expanded_url)) continue;
|
||||||
|
expansions.set(entity.url, entity.expanded_url);
|
||||||
|
}
|
||||||
|
if (expansions.size === 0) return text;
|
||||||
|
return text.replace(SHORTLINK, (link) => expansions.get(link) ?? link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The words of the post, as X itself would show them.
|
||||||
|
*
|
||||||
|
* A quote post gets a `t.co` link to the post it quotes stapled to the end of
|
||||||
|
* its text, and X hides it — the quoted post is on the page, so a shortlink
|
||||||
|
* to it is noise. `display_text_range` is where that boundary is, and it also
|
||||||
|
* keeps a link the author put there on purpose, which is why the range is
|
||||||
|
* used rather than stripping trailing URLs.
|
||||||
|
*
|
||||||
|
* Sliced before decoding: the indices are into the escaped text, where an
|
||||||
|
* ampersand is five characters. They are UTF-16 units, so a plain `slice` is
|
||||||
|
* right and splitting to codepoints first would overshoot past an emoji.
|
||||||
|
*/
|
||||||
|
function visibleText(result: TweetResult): string | undefined {
|
||||||
|
const raw = result.text;
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const range = result.display_text_range;
|
||||||
|
const shown = Array.isArray(range) ? raw.slice(range[0], range[1]) : raw;
|
||||||
|
// Expanded after the slice for the same reason: the range is measured
|
||||||
|
// against the text as it arrived.
|
||||||
|
const text = expandShortlinks(decodeEntities(shown), result).trimEnd();
|
||||||
|
return text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Every X post URL ends in `/status/<id>`, whatever precedes it. */
|
/** Every X post URL ends in `/status/<id>`, whatever precedes it. */
|
||||||
function tweetId(pathRest: string): string | undefined {
|
function tweetId(pathRest: string): string | undefined {
|
||||||
return /\/status(?:es)?\/(\d+)/.exec(`/${pathRest}`)?.[1];
|
return /\/status(?:es)?\/(\d+)/.exec(`/${pathRest}`)?.[1];
|
||||||
@@ -75,28 +149,58 @@ export function mediaFromDetails(details: MediaDetail[] | undefined): Media[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authorOf(user: TweetUser | undefined): Author {
|
||||||
|
const avatar = user?.profile_image_url_https;
|
||||||
|
return {
|
||||||
|
handle: `@${user?.screen_name ?? 'unknown'}`,
|
||||||
|
...(user?.name ? { displayName: user.name } : {}),
|
||||||
|
// `_normal` is a 48px thumbnail; `_x96` is the same file at 96.
|
||||||
|
...(avatar ? { avatar: { url: avatar.replace('_normal', '_x96') } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The post a quote post is talking about.
|
||||||
|
*
|
||||||
|
* Shown in full — whose it is, what it said and what it showed — rather than
|
||||||
|
* having its picture lifted out and hung under the quoter's name. Half the
|
||||||
|
* quote posts people share are someone answering a stranger, and the other
|
||||||
|
* half are someone continuing their own thought from an earlier post; both
|
||||||
|
* are unreadable with only one side of them on the page.
|
||||||
|
*
|
||||||
|
* The syndication payload carries no permalink for the quoted post, so its
|
||||||
|
* URL is rebuilt from the handle and the id, the same shape every X post has.
|
||||||
|
*/
|
||||||
|
export function quotedFrom(quoted: TweetResult | undefined): Quoted | undefined {
|
||||||
|
if (!quoted?.user) return undefined;
|
||||||
|
const handle = quoted.user.screen_name;
|
||||||
|
return {
|
||||||
|
author: authorOf(quoted.user),
|
||||||
|
...(visibleText(quoted) ? { text: visibleText(quoted) } : {}),
|
||||||
|
media: mediaFromDetails(quoted.mediaDetails),
|
||||||
|
...(quoted.created_at ? { postedAt: quoted.created_at } : {}),
|
||||||
|
...(handle && quoted.id_str
|
||||||
|
? { url: `https://x.com/${handle}/status/${quoted.id_str}` }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function toPost(result: TweetResult, originalUrl: string): Post {
|
export function toPost(result: TweetResult, originalUrl: string): Post {
|
||||||
const handle = result.user?.screen_name ?? 'unknown';
|
const quoted = quotedFrom(result.quoted_tweet);
|
||||||
const avatar = result.user?.profile_image_url_https;
|
|
||||||
return {
|
return {
|
||||||
platform: 'x',
|
platform: 'x',
|
||||||
platformLabel: 'X',
|
platformLabel: 'X',
|
||||||
originalUrl,
|
originalUrl,
|
||||||
author: {
|
author: authorOf(result.user),
|
||||||
handle: `@${handle}`,
|
|
||||||
...(result.user?.name ? { displayName: result.user.name } : {}),
|
|
||||||
// `_normal` is a 48px thumbnail; `_x96` is the same file at 96.
|
|
||||||
...(avatar ? { avatar: { url: avatar.replace('_normal', '_x96') } } : {}),
|
|
||||||
},
|
|
||||||
...(result.text ? { text: result.text } : {}),
|
|
||||||
textPosition: 'above',
|
textPosition: 'above',
|
||||||
// A quote post usually carries no media of its own — the picture being
|
segments: oneSegment({
|
||||||
// talked about belongs to the post it quotes, and that is the thing
|
...(visibleText(result) ? { text: visibleText(result) } : {}),
|
||||||
// worth showing.
|
// Only its own. The quoted post's pictures are shown with the quoted
|
||||||
media: mediaFromDetails(
|
// post, where it is clear whose they are.
|
||||||
result.mediaDetails?.length ? result.mediaDetails : result.quoted_tweet?.mediaDetails,
|
media: mediaFromDetails(result.mediaDetails),
|
||||||
),
|
...(result.created_at ? { postedAt: result.created_at } : {}),
|
||||||
...(result.created_at ? { postedAt: result.created_at } : {}),
|
...(quoted ? { quoted } : {}),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const ACCENT: Record<string, { color: string; monogram: string }> = {
|
|||||||
instagram: { color: '#c13584', monogram: 'IG' },
|
instagram: { color: '#c13584', monogram: 'IG' },
|
||||||
tiktok: { color: '#fe2c55', monogram: '♪' },
|
tiktok: { color: '#fe2c55', monogram: '♪' },
|
||||||
bluesky: { color: '#0085ff', monogram: 'B' },
|
bluesky: { color: '#0085ff', monogram: 'B' },
|
||||||
|
reddit: { color: '#ff4500', monogram: 'r' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function badge(platform: string, label: string): Raw {
|
export function badge(platform: string, label: string): Raw {
|
||||||
@@ -42,14 +43,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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { escapeHtml, raw, type Raw } from './html.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The small subset of Markdown people actually write in a Reddit comment.
|
||||||
|
*
|
||||||
|
* Not a Markdown implementation, and not trying to be one. Reddit hands us
|
||||||
|
* `body_html` too, but taking that would mean trusting markup a stranger
|
||||||
|
* caused to be generated; the rule here is the same as everywhere else in
|
||||||
|
* this project — escape first, then put back only the constructs we chose to
|
||||||
|
* support. Anything unrecognised stays as the literal characters the
|
||||||
|
* commenter typed, which is the safe way to be wrong.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REDDIT = 'https://www.reddit.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How an image in a comment becomes markup.
|
||||||
|
*
|
||||||
|
* Supplied by the caller rather than decided here, because the address has to
|
||||||
|
* go through the media proxy and this file knows nothing about that. Without
|
||||||
|
* one an image degrades to a link, which is what it was before.
|
||||||
|
*/
|
||||||
|
export type ImageRenderer = (url: string, alt: string) => string;
|
||||||
|
|
||||||
|
/** Worth showing as a picture rather than as a link to one. */
|
||||||
|
function looksLikeImage(url: string): boolean {
|
||||||
|
return /\.(jpe?g|png|gif|webp|avif)(\?|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute http(s) only. `javascript:` and friends never become links. */
|
||||||
|
function safeHref(url: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchor(href: string, label: string): string {
|
||||||
|
return `<a href="${escapeHtml(href)}" rel="noopener noreferrer nofollow" target="_blank">${escapeHtml(label)}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trailing punctuation is far more often the end of the sentence than part
|
||||||
|
* of the address, and an unmatched closing bracket cannot belong to a URL
|
||||||
|
* that has no opening one.
|
||||||
|
*/
|
||||||
|
function trimUrlTail(url: string): string {
|
||||||
|
let out = url;
|
||||||
|
for (;;) {
|
||||||
|
const last = out.at(-1) ?? '';
|
||||||
|
if (',.;:!?'.includes(last)) out = out.slice(0, -1);
|
||||||
|
else if (last === ')' && (out.match(/\(/g)?.length ?? 0) < (out.match(/\)/g)?.length ?? 0)) {
|
||||||
|
out = out.slice(0, -1);
|
||||||
|
} else return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const INLINE = new RegExp(
|
||||||
|
[
|
||||||
|
'`([^`\\n]+)`', // 1 code
|
||||||
|
// Before the link rule, or the `[` of an image matches as a link and
|
||||||
|
// leaves its `!` behind as text.
|
||||||
|
'!\\[([^\\]\\n]*)\\]\\(([^)\\s]+)\\)', // 2 alt, 3 src
|
||||||
|
'\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 4 label, 5 href
|
||||||
|
'\\*\\*([^*\\n]+)\\*\\*', // 6 strong
|
||||||
|
'~~([^~\\n]+)~~', // 7 strike
|
||||||
|
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 8 em with asterisks
|
||||||
|
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 9 em with underscores
|
||||||
|
'(https?://[^\\s<>]+)', // 10 bare url
|
||||||
|
// 11 the same thing with the scheme left off, which is how people type
|
||||||
|
// them. Narrow on purpose: a host, a path, and an image extension. Prose
|
||||||
|
// is full of dotted words, and `src/render/post.ts` must not become a
|
||||||
|
// link to a website in Tonga.
|
||||||
|
'(?<![\\w@/.])((?:[a-z0-9-]+\\.)+[a-z]{2,}/[^\\s<>]*\\.(?:jpe?g|png|gif|webp|avif)(?:\\?[^\\s<>]*)?)',
|
||||||
|
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 12 r/sub and u/name
|
||||||
|
].join('|'),
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** One line of body text: escaped, with the inline constructs put back. */
|
||||||
|
function inline(text: string, image?: ImageRenderer): string {
|
||||||
|
let out = '';
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (const match of text.matchAll(INLINE)) {
|
||||||
|
const [whole, code, alt, src, label, href, strong, strike, emStar, emScore, url, schemeless,
|
||||||
|
subOrUser] = match;
|
||||||
|
out += escapeHtml(text.slice(cursor, match.index));
|
||||||
|
cursor = match.index + whole.length;
|
||||||
|
|
||||||
|
if (code !== undefined) {
|
||||||
|
out += `<code>${escapeHtml(code)}</code>`;
|
||||||
|
} else if (src !== undefined) {
|
||||||
|
const safe = safeHref(src);
|
||||||
|
// Without a renderer to place it, an image is still a link to one.
|
||||||
|
out += safe ? (image ? image(safe, alt ?? '') : anchor(safe, alt || safe)) : escapeHtml(whole);
|
||||||
|
} else if (label !== undefined && href !== undefined) {
|
||||||
|
const safe = safeHref(href);
|
||||||
|
out += safe ? anchor(safe, label) : escapeHtml(whole);
|
||||||
|
} else if (strong !== undefined) {
|
||||||
|
out += `<strong>${inline(strong, image)}</strong>`;
|
||||||
|
} else if (strike !== undefined) {
|
||||||
|
out += `<del>${inline(strike, image)}</del>`;
|
||||||
|
} else if (emStar !== undefined || emScore !== undefined) {
|
||||||
|
out += `<em>${inline(emStar ?? emScore ?? '', image)}</em>`;
|
||||||
|
} else if (url !== undefined) {
|
||||||
|
const trimmed = trimUrlTail(url);
|
||||||
|
const safe = safeHref(trimmed);
|
||||||
|
const tail = escapeHtml(url.slice(trimmed.length));
|
||||||
|
if (!safe) {
|
||||||
|
out += escapeHtml(whole);
|
||||||
|
} else if (image && looksLikeImage(trimmed)) {
|
||||||
|
// People paste the address of a picture and mean the picture. On
|
||||||
|
// Reddit that is most of what an image in a comment even is.
|
||||||
|
out += image(safe, '') + tail;
|
||||||
|
} else {
|
||||||
|
out += anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + tail;
|
||||||
|
}
|
||||||
|
} else if (schemeless !== undefined) {
|
||||||
|
// Assumed https: every host that serves these redirects to it anyway,
|
||||||
|
// and a picture is the one thing worth guessing a scheme for.
|
||||||
|
const safe = safeHref(`https://${schemeless}`);
|
||||||
|
out += safe ? (image ? image(safe, '') : anchor(safe, schemeless)) : escapeHtml(whole);
|
||||||
|
} else if (subOrUser !== undefined) {
|
||||||
|
const path = subOrUser.startsWith('/') ? subOrUser : `/${subOrUser}`;
|
||||||
|
out += anchor(`${REDDIT}${path}`, subOrUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out + escapeHtml(text.slice(cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
const BULLET = /^\s{0,3}[-*+]\s+/;
|
||||||
|
const NUMBERED = /^\s{0,3}\d+[.)]\s+/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group lines into blocks and render each. Blocks are what separate a quote
|
||||||
|
* from the reply to it, and a comment that loses that separation reads as
|
||||||
|
* though the commenter said both halves.
|
||||||
|
*/
|
||||||
|
function blocks(lines: string[], image?: ImageRenderer): string {
|
||||||
|
let out = '';
|
||||||
|
let at = 0;
|
||||||
|
|
||||||
|
const takeWhile = (keep: (line: string) => boolean): string[] => {
|
||||||
|
const run: string[] = [];
|
||||||
|
while (at < lines.length && keep(lines[at] ?? '')) {
|
||||||
|
run.push(lines[at] ?? '');
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
return run;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (at < lines.length) {
|
||||||
|
const line = lines[at] ?? '';
|
||||||
|
|
||||||
|
if (line.trim() === '') {
|
||||||
|
at += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\s*```/.test(line)) {
|
||||||
|
at += 1;
|
||||||
|
const body = takeWhile((l) => !/^\s*```/.test(l));
|
||||||
|
if (at < lines.length) at += 1; // the closing fence
|
||||||
|
out += `<pre><code>${escapeHtml(body.join('\n'))}</code></pre>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^ {4}\S/.test(line)) {
|
||||||
|
const body = takeWhile((l) => /^ {4}/.test(l) || l.trim() === '');
|
||||||
|
out += `<pre><code>${escapeHtml(body.map((l) => l.slice(4)).join('\n').trimEnd())}</code></pre>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\s*>/.test(line)) {
|
||||||
|
const body = takeWhile((l) => /^\s*>/.test(l));
|
||||||
|
// Nested, so a quote of a quote keeps its shape.
|
||||||
|
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')), image)}</blockquote>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BULLET.test(line) || NUMBERED.test(line)) {
|
||||||
|
const ordered = !BULLET.test(line);
|
||||||
|
const pattern = ordered ? NUMBERED : BULLET;
|
||||||
|
const items = takeWhile((l) => pattern.test(l));
|
||||||
|
const tag = ordered ? 'ol' : 'ul';
|
||||||
|
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''), image)}</li>`).join('')}</${tag}>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paragraph = takeWhile(
|
||||||
|
(l) => l.trim() !== '' && !/^\s*>/.test(l) && !BULLET.test(l) && !NUMBERED.test(l) && !/^\s*```/.test(l),
|
||||||
|
);
|
||||||
|
out += `<p>${paragraph.map((l) => inline(l, image)).join('<br>')}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comment text, as safe markup. `image` places the pictures; without it
|
||||||
|
* they stay links, which is what they were before. */
|
||||||
|
export function renderMarkdown(text: string, image?: ImageRenderer): Raw {
|
||||||
|
return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n'), image));
|
||||||
|
}
|
||||||
+210
-18
@@ -1,23 +1,48 @@
|
|||||||
import { proxyUrlFor } from '../media/registry.ts';
|
import { proxyUrlFor } from '../media/registry.ts';
|
||||||
import type { Media, Post } from '../types.ts';
|
import { anchorOf, type Comment, type Media, type Post, type Quoted, type Segment } from '../types.ts';
|
||||||
import { html, type Raw } from './html.ts';
|
import { html, raw, type Raw } from './html.ts';
|
||||||
import { badge, layout, originalUrlBlock } from './layout.ts';
|
import { badge, layout, originalUrlBlock } from './layout.ts';
|
||||||
|
import { renderMarkdown } from './markdown.ts';
|
||||||
import { linkify } from './text.ts';
|
import { linkify } from './text.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put the ratio on the media itself, never on the figure around it. On the
|
||||||
|
* figure, at the full width of the column, a portrait video forces a box
|
||||||
|
* taller than the window and the rest of the post gets pushed off screen.
|
||||||
|
*/
|
||||||
function aspect(item: Media): string {
|
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}" style="${aspect(item)}">
|
return html`<figure class="item item--video" data-index="${index}">
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
style="${aspect(item)}"
|
||||||
${poster ? html`poster="${poster}"` : ''}
|
${poster ? html`poster="${poster}"` : ''}
|
||||||
><source src="${src}"></video>
|
><source src="${src}"></video>
|
||||||
${item.hls
|
${item.hls
|
||||||
@@ -26,10 +51,11 @@ function renderItem(item: Media, index: number): Raw {
|
|||||||
</figure>`;
|
</figure>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`<figure class="item item--image" data-index="${index}" style="${aspect(item)}">
|
return html`<figure class="item item--image" data-index="${index}">
|
||||||
<img
|
<img
|
||||||
src="${src}"
|
src="${src}"
|
||||||
alt="${item.alt ?? ''}"
|
alt="${item.alt ?? ''}"
|
||||||
|
style="${aspect(item)}"
|
||||||
loading="${index === 0 ? 'eager' : 'lazy'}"
|
loading="${index === 0 ? 'eager' : 'lazy'}"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
>
|
>
|
||||||
@@ -54,14 +80,17 @@ function renderMedia(media: Media[]): Raw {
|
|||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderText(post: Post): Raw {
|
function renderText(segment: Segment, post: Post): Raw {
|
||||||
if (!post.text) return html``;
|
if (!segment.text && !segment.title) return html``;
|
||||||
return html`<div class="text">${linkify(post.text, post.platform)}</div>`;
|
return html`<div class="text">
|
||||||
|
${segment.title ? html`<h1 class="text__title">${segment.title}</h1>` : ''}
|
||||||
|
${segment.text ? linkify(segment.text, post.platform) : ''}
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWhen(post: Post): Raw {
|
function renderWhen(postedAt: string | undefined): Raw {
|
||||||
if (!post.postedAt) return html``;
|
if (!postedAt) return html``;
|
||||||
const date = new Date(post.postedAt);
|
const date = new Date(postedAt);
|
||||||
if (Number.isNaN(date.getTime())) return html``;
|
if (Number.isNaN(date.getTime())) return html``;
|
||||||
return html`<time datetime="${date.toISOString()}">${date.toLocaleString('en-CA', {
|
return html`<time datetime="${date.toISOString()}">${date.toLocaleString('en-CA', {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
@@ -69,11 +98,175 @@ function renderWhen(post: Post): Raw {
|
|||||||
})}</time>`;
|
})}</time>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The post being quoted, drawn as a post of its own inside the one quoting
|
||||||
|
* it: its author, its words, its pictures, and a way to open it.
|
||||||
|
*
|
||||||
|
* The author line is the point. Lifting only the media out — which is what
|
||||||
|
* this used to do — put someone else's picture under the quoter's name with
|
||||||
|
* nothing to say so.
|
||||||
|
*/
|
||||||
|
function renderQuoted(quoted: Quoted, post: Post): Raw {
|
||||||
|
const avatar = quoted.author.avatar ? proxyUrlFor(quoted.author.avatar) : undefined;
|
||||||
|
|
||||||
|
return html`<blockquote class="quote">
|
||||||
|
<header class="quote__who">
|
||||||
|
${avatar ? html`<img class="quote__avatar" src="${avatar}" alt="" loading="lazy">` : ''}
|
||||||
|
${quoted.author.displayName
|
||||||
|
? html`<span class="quote__name">${quoted.author.displayName}</span>`
|
||||||
|
: ''}
|
||||||
|
<span class="quote__handle">${quoted.author.handle}</span>
|
||||||
|
${renderWhen(quoted.postedAt)}
|
||||||
|
</header>
|
||||||
|
${quoted.text ? html`<div class="quote__text">${linkify(quoted.text, post.platform)}</div>` : ''}
|
||||||
|
${renderMedia(quoted.media)}
|
||||||
|
${quoted.url
|
||||||
|
? html`<a class="quote__open" href="${quoted.url}" rel="noopener noreferrer nofollow" target="_blank">
|
||||||
|
Open the quoted post on ${post.platformLabel}
|
||||||
|
</a>`
|
||||||
|
: ''}
|
||||||
|
</blockquote>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One post in the chain. When there is only one, the wrapper is invisible;
|
||||||
|
* when there are several, each is separated and the one that was actually
|
||||||
|
* linked is marked, since it may not be the first.
|
||||||
|
*/
|
||||||
|
function renderSegment(segment: Segment, post: Post, threaded: boolean): Raw {
|
||||||
|
const above = post.textPosition === 'above';
|
||||||
|
return html`<section class="segment" ${threaded && segment.isAnchor ? raw('data-anchor="true"') : ''} ${
|
||||||
|
segment.quoted ? raw('data-quoted="true"') : ''
|
||||||
|
}>
|
||||||
|
${threaded && segment.isAnchor
|
||||||
|
? html`<p class="segment__mark">the post you followed</p>`
|
||||||
|
: ''}
|
||||||
|
${above ? renderText(segment, post) : ''}
|
||||||
|
${renderMedia(segment.media)}
|
||||||
|
${above ? '' : renderText(segment, post)}
|
||||||
|
${segment.quoted ? renderQuoted(segment.quoted, post) : ''}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short enough to sit on a comment's byline without wrapping. Absolute
|
||||||
|
* rather than "3 hours ago", because the page is cached for an hour and a
|
||||||
|
* relative time would quietly become wrong while it sat there. */
|
||||||
|
function shortWhen(postedAt: string | undefined): Raw {
|
||||||
|
if (!postedAt) return html``;
|
||||||
|
const date = new Date(postedAt);
|
||||||
|
if (Number.isNaN(date.getTime())) return html``;
|
||||||
|
return html`<time datetime="${date.toISOString()}">${date.toLocaleString('en-CA', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}</time>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A picture inside a comment.
|
||||||
|
*
|
||||||
|
* Through the proxy like everything else — a comment full of `preview.redd.it`
|
||||||
|
* addresses would otherwise have the viewer's browser fetch every one of them
|
||||||
|
* straight from Reddit, which is the thing this whole app exists to avoid.
|
||||||
|
*
|
||||||
|
* No dimensions to reserve space with: the size is in the payload but not in
|
||||||
|
* the Markdown, so these are capped by the stylesheet and load at whatever
|
||||||
|
* shape they are.
|
||||||
|
*/
|
||||||
|
function renderCommentImage(url: string, alt: string): string {
|
||||||
|
return html`<img class="c__img" src="${proxyUrlFor({ url })}" alt="${alt}" loading="lazy" decoding="async">`
|
||||||
|
.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything hanging off a comment, however deep. Shown only while it is
|
||||||
|
* collapsed, so what a fold is hiding is never a mystery. */
|
||||||
|
function descendantsOf(comment: Comment): number {
|
||||||
|
return comment.replies.reduce((total, reply) => total + 1 + descendantsOf(reply), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One comment and everything under it.
|
||||||
|
*
|
||||||
|
* A `<details>` per comment, so collapsing works with the stylesheet turned
|
||||||
|
* off and the keyboard alone, and takes the whole subtree with it because
|
||||||
|
* the replies are nested inside rather than listed alongside.
|
||||||
|
*/
|
||||||
|
function renderComment(comment: Comment, depth: number): Raw {
|
||||||
|
const marks = [
|
||||||
|
comment.isAuthor ? 'OP' : undefined,
|
||||||
|
comment.distinguished === 'moderator' ? 'MOD' : undefined,
|
||||||
|
comment.distinguished === 'admin' ? 'ADMIN' : undefined,
|
||||||
|
].filter((mark) => mark !== undefined);
|
||||||
|
|
||||||
|
return html`<details class="c" open data-depth="${depth}">
|
||||||
|
<summary class="c__head">
|
||||||
|
<span class="c__author">${comment.author}</span>
|
||||||
|
${marks.map((mark) => html`<span class="c__mark c__mark--${mark.toLowerCase()}">${mark}</span>`)}
|
||||||
|
${typeof comment.score === 'number'
|
||||||
|
? html`<span class="c__score">${comment.score} ${Math.abs(comment.score) === 1 ? 'point' : 'points'}</span>`
|
||||||
|
: ''}
|
||||||
|
${shortWhen(comment.postedAt)}
|
||||||
|
${descendantsOf(comment) > 0
|
||||||
|
? html`<span class="c__hidden">+${descendantsOf(comment)} ${
|
||||||
|
descendantsOf(comment) === 1 ? 'reply' : 'replies'
|
||||||
|
}</span>`
|
||||||
|
: ''}
|
||||||
|
</summary>
|
||||||
|
${comment.text
|
||||||
|
? html`<div class="c__body">${renderMarkdown(comment.text, renderCommentImage)}</div>`
|
||||||
|
: ''}
|
||||||
|
${comment.replies.length || comment.moreReplies
|
||||||
|
? html`<div class="c__replies">
|
||||||
|
${comment.replies.map((reply) => renderComment(reply, depth + 1))}
|
||||||
|
${comment.moreReplies
|
||||||
|
? html`<p class="c__more">${comment.moreReplies} more ${
|
||||||
|
comment.moreReplies === 1 ? 'reply' : 'replies'
|
||||||
|
}, on Reddit</p>`
|
||||||
|
: ''}
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The conversation under the post, where the platform has one.
|
||||||
|
*
|
||||||
|
* Everything the first page carried, in the order it was ranked. What is
|
||||||
|
* missing is what was behind a "load more" — following those means going to
|
||||||
|
* the platform, so they are counted rather than pretended away.
|
||||||
|
*/
|
||||||
|
function renderComments(post: Post): Raw {
|
||||||
|
const comments = post.comments;
|
||||||
|
if (!comments?.length) return html``;
|
||||||
|
|
||||||
|
const threads = comments.length;
|
||||||
|
const shown = comments.reduce((total, comment) => total + 1 + descendantsOf(comment), 0);
|
||||||
|
const total = post.commentCount;
|
||||||
|
|
||||||
|
return html`<section class="thread" data-platform="${post.platform}">
|
||||||
|
<header class="thread__head">
|
||||||
|
<h2 class="thread__title">Comments</h2>
|
||||||
|
<span class="thread__count">${
|
||||||
|
total !== undefined && total > shown ? html`${shown} of ${total}` : html`${shown}`
|
||||||
|
}, in ${threads} ${threads === 1 ? 'thread' : 'threads'}</span>
|
||||||
|
<button type="button" class="thread__toggle" data-collapsed="false" hidden>Collapse all</button>
|
||||||
|
</header>
|
||||||
|
<div class="thread__list">${comments.map((comment) => renderComment(comment, 0))}</div>
|
||||||
|
${post.moreComments
|
||||||
|
? html`<p class="thread__more">${post.moreComments} more, behind “load more” on Reddit.</p>`
|
||||||
|
: ''}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderPost(post: Post): string {
|
export function renderPost(post: Post): string {
|
||||||
const avatar = post.author.avatar ? proxyUrlFor(post.author.avatar) : undefined;
|
const avatar = post.author.avatar ? proxyUrlFor(post.author.avatar) : undefined;
|
||||||
const above = post.textPosition === 'above';
|
const threaded = post.segments.length > 1;
|
||||||
|
const anchor = anchorOf(post);
|
||||||
|
|
||||||
const body = html`<article class="post" data-platform="${post.platform}">
|
const body = html`<article class="post" data-platform="${post.platform}" ${
|
||||||
|
threaded ? raw(`data-segments="${post.segments.length}"`) : ''
|
||||||
|
}>
|
||||||
<header class="post__head">
|
<header class="post__head">
|
||||||
${badge(post.platform, post.platformLabel)}
|
${badge(post.platform, post.platformLabel)}
|
||||||
<div class="who">
|
<div class="who">
|
||||||
@@ -85,15 +278,14 @@ export function renderPost(post: Post): string {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
${above ? renderText(post) : ''}
|
${post.segments.map((segment) => renderSegment(segment, post, threaded))}
|
||||||
${renderMedia(post.media)}
|
|
||||||
${above ? '' : renderText(post)}
|
|
||||||
|
|
||||||
<footer class="post__foot">
|
<footer class="post__foot">
|
||||||
${renderWhen(post)}
|
${renderWhen(anchor?.postedAt)}
|
||||||
${originalUrlBlock(post.originalUrl, post.platformLabel)}
|
${originalUrlBlock(post.originalUrl, post.platformLabel)}
|
||||||
</footer>
|
</footer>
|
||||||
</article>`;
|
</article>
|
||||||
|
${renderComments(post)}`;
|
||||||
|
|
||||||
const who = post.author.displayName ?? post.author.handle;
|
const who = post.author.displayName ?? post.author.handle;
|
||||||
return layout(`${who} on ${post.platformLabel}`, body);
|
return layout(`${who} on ${post.platformLabel}`, body);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const PROFILE_BASE: Record<PlatformId, string> = {
|
|||||||
instagram: 'https://www.instagram.com/',
|
instagram: 'https://www.instagram.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/',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TAG_BASE: Partial<Record<PlatformId, string>> = {
|
const TAG_BASE: Partial<Record<PlatformId, string>> = {
|
||||||
|
|||||||
+89
-6
@@ -1,5 +1,5 @@
|
|||||||
/** The five platforms v1 understands. */
|
/** The platforms antisocial understands. */
|
||||||
export type PlatformId = 'x' | 'threads' | 'instagram' | 'tiktok' | 'bluesky';
|
export type PlatformId = 'x' | 'threads' | 'instagram' | 'tiktok' | 'bluesky' | 'reddit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Something fetchable that lives on someone else's CDN.
|
* Something fetchable that lives on someone else's CDN.
|
||||||
@@ -40,6 +40,69 @@ export type Author = {
|
|||||||
avatar?: Asset;
|
avatar?: Asset;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A post that the post being shown is talking about.
|
||||||
|
*
|
||||||
|
* Held apart from the segment quoting it rather than folded into it. The
|
||||||
|
* words and the pictures belong to someone else, and showing them under the
|
||||||
|
* quoter's name — which is what folding them in amounts to — tells the
|
||||||
|
* reader something untrue about who said what.
|
||||||
|
*/
|
||||||
|
export type Quoted = {
|
||||||
|
author: Author;
|
||||||
|
text?: string;
|
||||||
|
media: Media[];
|
||||||
|
/** ISO 8601. */
|
||||||
|
postedAt?: string;
|
||||||
|
/** The quoted post's own URL, so it can be opened on its own. */
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One post. Usually a whole `Post` is a single segment, but on the platforms
|
||||||
|
* where people write in chains — Bluesky and Threads — the author's own
|
||||||
|
* follow-ups belong with the one that was linked, and other people's replies
|
||||||
|
* do not.
|
||||||
|
*/
|
||||||
|
export type Segment = {
|
||||||
|
/** Only Reddit gives a post a headline of its own. Everywhere else the
|
||||||
|
* first words of the body do that job, so this is left unset. */
|
||||||
|
title?: string;
|
||||||
|
text?: string;
|
||||||
|
media: Media[];
|
||||||
|
/** ISO 8601. */
|
||||||
|
postedAt?: string;
|
||||||
|
/** The post this one quotes, where there is one. */
|
||||||
|
quoted?: Quoted;
|
||||||
|
/** The post the link actually pointed at. Only meaningful when a thread
|
||||||
|
* has more than one segment. */
|
||||||
|
isAnchor?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One comment, with the replies that hang off it.
|
||||||
|
*
|
||||||
|
* A tree rather than a flat list with depths: collapsing a comment has to
|
||||||
|
* take everything under it along, and nesting is what makes that free.
|
||||||
|
*/
|
||||||
|
export type Comment = {
|
||||||
|
/** Already prefixed, e.g. `u/someone`. `[deleted]` is left as it came. */
|
||||||
|
author: string;
|
||||||
|
text?: string;
|
||||||
|
/** ISO 8601. */
|
||||||
|
postedAt?: string;
|
||||||
|
/** Absent when the platform is still hiding it on a new comment. */
|
||||||
|
score?: number;
|
||||||
|
/** The author of the post, replying under it. */
|
||||||
|
isAuthor?: boolean;
|
||||||
|
/** Marked by the platform as a moderator or admin comment. */
|
||||||
|
distinguished?: string;
|
||||||
|
replies: Comment[];
|
||||||
|
/** Replies that exist upstream but were not on the page we were given.
|
||||||
|
* Shown as a count, since following them means going to the platform. */
|
||||||
|
moreReplies?: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single shape every adapter produces and the renderer consumes. Adding
|
* The single shape every adapter produces and the renderer consumes. Adding
|
||||||
* a platform means producing one of these; nothing downstream changes.
|
* a platform means producing one of these; nothing downstream changes.
|
||||||
@@ -52,15 +115,35 @@ export type Post = {
|
|||||||
* the copy button hands back. */
|
* the copy button hands back. */
|
||||||
originalUrl: string;
|
originalUrl: string;
|
||||||
author: Author;
|
author: Author;
|
||||||
text?: string;
|
|
||||||
/** Fixed per platform: the ones that lead with words put the text above
|
/** Fixed per platform: the ones that lead with words put the text above
|
||||||
* the media, the ones that lead with pictures put it below. */
|
* the media, the ones that lead with pictures put it below. */
|
||||||
textPosition: 'above' | 'below';
|
textPosition: 'above' | 'below';
|
||||||
media: Media[];
|
/** In the order they were written. Never empty. */
|
||||||
/** ISO 8601. */
|
segments: Segment[];
|
||||||
postedAt?: string;
|
/**
|
||||||
|
* The conversation under the post, where the platform has one worth
|
||||||
|
* showing. Only Reddit fills this in: elsewhere the replies are strangers
|
||||||
|
* arguing beneath something that was shared for its own sake, but on
|
||||||
|
* Reddit the thread is usually the point of the link.
|
||||||
|
*/
|
||||||
|
comments?: Comment[];
|
||||||
|
/** Top-level comments the first page did not carry. */
|
||||||
|
moreComments?: number;
|
||||||
|
/** What the platform says the total is, which is larger than what we
|
||||||
|
* show whenever `moreComments` is set. */
|
||||||
|
commentCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Most platforms have no notion of a chain, so their adapters use this. */
|
||||||
|
export function oneSegment(segment: Segment): Segment[] {
|
||||||
|
return [{ ...segment, isAnchor: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The segment the link pointed at, or the first one. */
|
||||||
|
export function anchorOf(post: Post): Segment | undefined {
|
||||||
|
return post.segments.find((s) => s.isAnchor) ?? post.segments[0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown when a post cannot be resolved. Carries enough for the error card
|
* Thrown when a post cannot be resolved. Carries enough for the error card
|
||||||
* to still be useful: which platform, and the link to hand back.
|
* to still be useful: which platform, and the link to hand back.
|
||||||
|
|||||||
+127
-17
@@ -1,30 +1,35 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { mediaFromEmbed, toPost } from '../src/platforms/bluesky.ts';
|
import { mediaFromEmbed, quotedFrom, selfThread, toPost } from '../src/platforms/bluesky.ts';
|
||||||
import { fixture } from './helpers.ts';
|
import { fixture } from './helpers.ts';
|
||||||
|
|
||||||
const URL_ = 'https://bsky.app/profile/example/post/abc';
|
const URL_ = 'https://bsky.app/profile/example/post/abc';
|
||||||
|
|
||||||
|
/** The three single-post fixtures are captured `thread.post` objects. */
|
||||||
|
function single(name: string) {
|
||||||
|
return toPost({ post: fixture(`bluesky/${name}.json`) }, URL_);
|
||||||
|
}
|
||||||
|
|
||||||
test('a text-only post carries no media', () => {
|
test('a text-only post carries no media', () => {
|
||||||
const post = toPost(fixture('bluesky/text-only.json'), URL_);
|
const post = single('text-only');
|
||||||
assert.equal(post.platform, 'bluesky');
|
assert.equal(post.platform, 'bluesky');
|
||||||
assert.equal(post.textPosition, 'above');
|
assert.equal(post.textPosition, 'above');
|
||||||
assert.deepEqual(post.media, []);
|
assert.equal(post.segments.length, 1);
|
||||||
assert.ok(post.text);
|
assert.deepEqual(post.segments[0]?.media, []);
|
||||||
|
assert.ok(post.segments[0]?.text);
|
||||||
assert.match(post.author.handle, /^@/);
|
assert.match(post.author.handle, /^@/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an image post keeps every image, in order, with alt text', () => {
|
test('an image post keeps every image, in order, with alt text', () => {
|
||||||
const post = toPost(fixture('bluesky/images.json'), URL_);
|
const media = single('images').segments[0]?.media ?? [];
|
||||||
assert.ok(post.media.length > 1, 'expected a multi-image post');
|
assert.ok(media.length > 1, 'expected a multi-image post');
|
||||||
assert.ok(post.media.every((m) => m.kind === 'image'));
|
assert.ok(media.every((m) => m.kind === 'image'));
|
||||||
assert.ok(post.media.every((m) => m.url.startsWith('https://')));
|
assert.ok(media.every((m) => m.url.startsWith('https://')));
|
||||||
assert.ok(post.media.some((m) => m.kind === 'image' && m.alt));
|
assert.ok(media.some((m) => m.kind === 'image' && m.alt));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a video post is a direct HLS playlist with a poster', () => {
|
test('a video post is a direct HLS playlist with a poster', () => {
|
||||||
const post = toPost(fixture('bluesky/video.json'), URL_);
|
const item = single('video').segments[0]?.media[0];
|
||||||
const [item] = post.media;
|
|
||||||
assert.equal(item?.kind, 'video');
|
assert.equal(item?.kind, 'video');
|
||||||
assert.ok(item?.kind === 'video' && item.hls);
|
assert.ok(item?.kind === 'video' && item.hls);
|
||||||
// Proxying HLS would mean rewriting the manifest, so it is linked direct.
|
// Proxying HLS would mean rewriting the manifest, so it is linked direct.
|
||||||
@@ -40,12 +45,117 @@ test('a quote post with media reaches through the nested embed', () => {
|
|||||||
assert.deepEqual(media, [{ kind: 'image', url: 'https://example/i.jpg' }]);
|
assert.deepEqual(media, [{ kind: 'image', url: 'https://example/i.jpg' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a quote post shows the media of the post it quotes', () => {
|
test('a quote post carries the post it quotes, whose it is included', () => {
|
||||||
const media = mediaFromEmbed({
|
const segment = toPost(fixture('bluesky/quote.json'), URL_).segments[0];
|
||||||
$type: 'app.bsky.embed.record#view',
|
const quoted = segment?.quoted;
|
||||||
record: {
|
|
||||||
embeds: [{ $type: 'app.bsky.embed.images#view', images: [{ fullsize: 'https://cdn/q.jpg' }] }],
|
assert.ok(quoted, 'the quoted post must survive');
|
||||||
|
assert.equal(quoted?.author.handle, '@cpsc.gov');
|
||||||
|
assert.equal(quoted?.author.displayName, 'U.S. Consumer Product Safety Commission');
|
||||||
|
assert.match(quoted?.text ?? '', /Longer videos is great/);
|
||||||
|
assert.ok(quoted?.postedAt);
|
||||||
|
// `at://` is not an address anyone can open, so the web one is rebuilt.
|
||||||
|
assert.equal(quoted?.url, 'https://bsky.app/profile/cpsc.gov/post/3mtzkm3hmo222');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the quoted post keeps its own media instead of lending it to the quoter', () => {
|
||||||
|
const segment = toPost(fixture('bluesky/quote.json'), URL_).segments[0];
|
||||||
|
|
||||||
|
assert.deepEqual(segment?.media, [], 'the quoter attached nothing');
|
||||||
|
assert.equal(segment?.quoted?.media.length, 1);
|
||||||
|
assert.equal(segment?.quoted?.media[0]?.kind, 'video');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a quote with media of its own nests the quoted record one level deeper', () => {
|
||||||
|
// `recordWithMedia` puts it at `embed.record.record`; a plain quote puts it
|
||||||
|
// at `embed.record`. Reading only the first shape finds nothing here.
|
||||||
|
const quoted = toPost(fixture('bluesky/quote-with-media.json'), URL_).segments[0]?.quoted;
|
||||||
|
assert.equal(quoted?.author.handle, '@quillmatiq.com');
|
||||||
|
assert.match(quoted?.text ?? '', /huge value add/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a quote pointing at something that is not a post is left out', () => {
|
||||||
|
// A quote can point at a feed, a list, or a post since deleted or blocked.
|
||||||
|
// They arrive in the same slot under a different type, carrying none of
|
||||||
|
// what a post has.
|
||||||
|
for (const $type of [
|
||||||
|
'app.bsky.embed.record#viewNotFound',
|
||||||
|
'app.bsky.embed.record#viewBlocked',
|
||||||
|
'app.bsky.embed.record#viewDetached',
|
||||||
|
'app.bsky.feed.defs#generatorView',
|
||||||
|
]) {
|
||||||
|
assert.equal(quotedFrom({ $type: 'app.bsky.embed.record#view', record: { $type } }), undefined);
|
||||||
|
}
|
||||||
|
assert.equal(quotedFrom(undefined), undefined);
|
||||||
|
assert.equal(quotedFrom({ $type: 'app.bsky.embed.images#view' }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The chain fixture is a real thread. Other people's replies were trimmed to
|
||||||
|
// two per level for size, but deliberately kept: the whole point is that they
|
||||||
|
// are there in the payload and must not appear in the output.
|
||||||
|
test("the author's own chain is followed, in order, and marked", () => {
|
||||||
|
const post = toPost(fixture('bluesky/thread-chain.json'), URL_);
|
||||||
|
|
||||||
|
assert.equal(post.segments.length, 6, 'anchor plus five follow-ups');
|
||||||
|
assert.equal(post.segments[0]?.isAnchor, true, 'the linked post leads the chain here');
|
||||||
|
assert.equal(post.segments.filter((s) => s.isAnchor).length, 1);
|
||||||
|
|
||||||
|
const times = post.segments.map((s) => s.postedAt ?? '');
|
||||||
|
assert.deepEqual([...times].sort(), times, 'segments must be in the order written');
|
||||||
|
|
||||||
|
assert.match(post.segments[0]?.text ?? '', /redesigning the notifications tab/);
|
||||||
|
assert.match(post.segments[5]?.text ?? '', /Let us know what works/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("other people's replies never appear in the thread", () => {
|
||||||
|
const chain = selfThread(fixture('bluesky/thread-chain.json'));
|
||||||
|
const texts = chain.map((s) => s.text ?? '').join('\n');
|
||||||
|
|
||||||
|
// All present in the fixture as replies to the same posts.
|
||||||
|
for (const stranger of ['Really? One of the most visited?', 'Show us notifications from muted', 'WHY ARE YOU NOT USING DARK MODE']) {
|
||||||
|
assert.ok(!texts.includes(stranger), `a reply leaked into the thread: ${stranger}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a post with no thread around it is a single segment', () => {
|
||||||
|
const chain = selfThread({
|
||||||
|
post: {
|
||||||
|
uri: 'at://x/app.bsky.feed.post/1',
|
||||||
|
author: { handle: 'someone.bsky.social' },
|
||||||
|
record: { text: 'alone' },
|
||||||
|
},
|
||||||
|
replies: [
|
||||||
|
{ post: { uri: 'at://y/1', author: { handle: 'other.bsky.social' }, record: { text: 'hi' } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(chain.length, 1);
|
||||||
|
assert.equal(chain[0]?.isAnchor, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a linked post mid-thread keeps the posts that came before it', () => {
|
||||||
|
const chain = selfThread({
|
||||||
|
post: { uri: 'at://a/2', author: { handle: 'me.bsky.social' }, record: { text: 'second' } },
|
||||||
|
parent: {
|
||||||
|
post: { uri: 'at://a/1', author: { handle: 'me.bsky.social' }, record: { text: 'first' } },
|
||||||
|
},
|
||||||
|
replies: [
|
||||||
|
{ post: { uri: 'at://a/3', author: { handle: 'me.bsky.social' }, record: { text: 'third' } } },
|
||||||
|
{ post: { uri: 'at://b/1', author: { handle: 'other.bsky.social' }, record: { text: 'nope' } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(chain.map((s) => s.text), ['first', 'second', 'third']);
|
||||||
|
assert.deepEqual(chain.map((s) => s.isAnchor), [undefined, true, undefined]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the walk up stops at the first post by someone else', () => {
|
||||||
|
const chain = selfThread({
|
||||||
|
post: { uri: 'at://a/2', author: { handle: 'me.bsky.social' }, record: { text: 'mine' } },
|
||||||
|
parent: {
|
||||||
|
post: { uri: 'at://b/1', author: { handle: 'other.bsky.social' }, record: { text: 'theirs' } },
|
||||||
|
parent: {
|
||||||
|
post: { uri: 'at://a/0', author: { handle: 'me.bsky.social' }, record: { text: 'older' } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.deepEqual(media, [{ kind: 'image', url: 'https://cdn/q.jpg' }]);
|
assert.deepEqual(chain.map((s) => s.text), ['mine']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:4hodhjl2kposuchzvpiviwps/app.bsky.feed.post/3mrvmbtqn2c24",
|
||||||
|
"author": {
|
||||||
|
"handle": "brittanyellich.com",
|
||||||
|
"displayName": "Brittany Ellich",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hodhjl2kposuchzvpiviwps/bafkreidaibgqtw5rstbp6vmjvyv3zata5whjmhw2qerrbeqaq3gy7cfyzq"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"text": "Thanks to this suggestion, you can now schedule updates to @attie.ai pages to build your own personal newsletter about whatever it is you care about ❤️\n\nattie.ai/@brittanyell...",
|
||||||
|
"createdAt": "2026-07-30T23:37:54.005Z"
|
||||||
|
},
|
||||||
|
"embed": {
|
||||||
|
"$type": "app.bsky.embed.recordWithMedia#view",
|
||||||
|
"media": {
|
||||||
|
"$type": "app.bsky.embed.external#view"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"record": {
|
||||||
|
"$type": "app.bsky.embed.record#viewRecord",
|
||||||
|
"uri": "at://did:plc:xgvzy7ni6ig6ievcbls5jaxe/app.bsky.feed.post/3mrdeskslm222",
|
||||||
|
"author": {
|
||||||
|
"handle": "quillmatiq.com",
|
||||||
|
"displayName": "Anuj Ahooja",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:xgvzy7ni6ig6ievcbls5jaxe/bafkreiaumjzoigdvnldguuxu3ytpzwglqgy3dckzg5cxwqcp5yjychrf4e"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"text": "I think a huge value add here would be to have these pages periodically query for new info and create new versions of the pages, along with a notification (maybe on Bluesky or over email for visibility). A manual refresh button would be a good start!\n\ncc @jay.bsky.team @pfrazee.com",
|
||||||
|
"createdAt": "2026-07-23T17:36:09.832Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+41
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3mtzvbrnbds2q",
|
||||||
|
"author": {
|
||||||
|
"handle": "bsky.app",
|
||||||
|
"displayName": "Bluesky",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihwihm6kpd6zuwhhlro75p5qks5qtrcu55jp3gddbfjsieiv7wuka"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"text": "NOW PLAYING: \"Beats To Relax / Be Safe To,\" by @cpsc.gov",
|
||||||
|
"createdAt": "2026-08-27T03:19:57.683Z"
|
||||||
|
},
|
||||||
|
"embed": {
|
||||||
|
"$type": "app.bsky.embed.record#view",
|
||||||
|
"record": {
|
||||||
|
"$type": "app.bsky.embed.record#viewRecord",
|
||||||
|
"uri": "at://did:plc:wbmw5r5a2j7txv72ggne2uoe/app.bsky.feed.post/3mtzkm3hmo222",
|
||||||
|
"author": {
|
||||||
|
"handle": "cpsc.gov",
|
||||||
|
"displayName": "U.S. Consumer Product Safety Commission",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:wbmw5r5a2j7txv72ggne2uoe/bafkreic44z27sfbvclf2h2xjmsfbglhyqiobn2kigoayodampyvbiml3m4"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"text": "Longer videos is great.",
|
||||||
|
"createdAt": "2026-08-27T00:08:52.368Z"
|
||||||
|
},
|
||||||
|
"embeds": [
|
||||||
|
{
|
||||||
|
"$type": "app.bsky.embed.video#view",
|
||||||
|
"playlist": "https://video.bsky.app/watch/did%3Aplc%3Awbmw5r5a2j7txv72ggne2uoe/bafkreia7e6rm7lq6k74ttdlbyx25g37eng3llqm7pex4fheah5voznais4/playlist.m3u8",
|
||||||
|
"thumbnail": "https://video.bsky.app/watch/did%3Aplc%3Awbmw5r5a2j7txv72ggne2uoe/bafkreia7e6rm7lq6k74ttdlbyx25g37eng3llqm7pex4fheah5voznais4/thumbnail.jpg",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1920
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+321
@@ -0,0 +1,321 @@
|
|||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7inwsws2k",
|
||||||
|
"author": {
|
||||||
|
"handle": "danielle.bsky.team",
|
||||||
|
"displayName": "danielleyuhan",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T21:40:10.285Z",
|
||||||
|
"text": "We're redesigning the notifications tab! \ud83d\udd14 It's one of the most visited surfaces on the app, and a lot of you have told us it could be better. Here's a look at what we're exploring. \ud83e\uddf5"
|
||||||
|
},
|
||||||
|
"embed": {
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreiaifo3c4pchs2qtgbyblpt3r35eqngodxzckksiti6ufrnw7e3uoq",
|
||||||
|
"fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreiaifo3c4pchs2qtgbyblpt3r35eqngodxzckksiti6ufrnw7e3uoq",
|
||||||
|
"alt": "\"All\" tab that shows all your notifications in chronological order.",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 1704,
|
||||||
|
"width": 786
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreiezmgsafcbrgbqcaoxf4uu6qwcb4vxsjngtwpoylznut52imhdjue",
|
||||||
|
"fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreiezmgsafcbrgbqcaoxf4uu6qwcb4vxsjngtwpoylznut52imhdjue",
|
||||||
|
"alt": "\"Follows\" tab that shows you a list of people who follow you, so you never miss a follow-back.",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 1704,
|
||||||
|
"width": 786
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreieigolasgenhs4guectwhrprc4te6el7fosn5ayzexpxjh6tk565i",
|
||||||
|
"fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreieigolasgenhs4guectwhrprc4te6el7fosn5ayzexpxjh6tk565i",
|
||||||
|
"alt": "\"Conversations\" tab that allows you to view mentions, replies, and quotes all in one place. ",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 1704,
|
||||||
|
"width": 786
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreihtzrgrltxhxcnrzp6zkn2bkkhyd5fhu5hp3xtypf4h24q5x5ygei",
|
||||||
|
"fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreihtzrgrltxhxcnrzp6zkn2bkkhyd5fhu5hp3xtypf4h24q5x5ygei",
|
||||||
|
"alt": "\"Activity\" tab that shows all the posts from accounts you've subscribed to.",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 1704,
|
||||||
|
"width": 786
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"$type": "app.bsky.embed.images#view"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replies": [
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7ipjjyc2k",
|
||||||
|
"author": {
|
||||||
|
"handle": "danielle.bsky.team",
|
||||||
|
"displayName": "danielleyuhan",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T21:40:10.286Z",
|
||||||
|
"text": "We went through a ton of feedback you've shared. Big themes: important stuff gets buried (replies lost under likes, missed replies from people you care about), too much noise, hard to scan, and not enough control over which notifications you see. This design is our first pass at addressing these."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replies": [
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7ipjkxk2k",
|
||||||
|
"author": {
|
||||||
|
"handle": "danielle.bsky.team",
|
||||||
|
"displayName": "danielleyuhan",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T21:40:10.287Z",
|
||||||
|
"text": "The biggest change is tabs. All = everything, chronological. People you follow = just notifications from people you follow. Follows = a list of people who follow you, so you never miss a follow-back. Conversations = replies, mentions and quotes. Activity = posts from accounts you've subscribed to."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replies": [
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7ipjlws2k",
|
||||||
|
"author": {
|
||||||
|
"handle": "danielle.bsky.team",
|
||||||
|
"displayName": "danielleyuhan",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T21:40:10.288Z",
|
||||||
|
"text": "Some additional UX improvements include \"today\" and \"earlier\" sections so you know what's new at a glance, and like/reply buttons that change once you've responded so you can keep track of which conversations you've already answered."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replies": [
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7ipjmw22k",
|
||||||
|
"author": {
|
||||||
|
"handle": "danielle.bsky.team",
|
||||||
|
"displayName": "danielleyuhan",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T21:40:10.289Z",
|
||||||
|
"text": "Also, when someone new follows you, you\u2019ll now see \u201cmutual followers\u201d (the people you follow who follow them too). Replies show a preview of the post they're responding to, and images render smaller as thumbnails so each notification takes less room and more fit on screen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replies": [
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7ipjmw32k",
|
||||||
|
"author": {
|
||||||
|
"handle": "danielle.bsky.team",
|
||||||
|
"displayName": "danielleyuhan",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T21:40:10.290Z",
|
||||||
|
"text": "Let us know what works or what's missing. We'll read everything \ud83d\ude42"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replies": [
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:zeytbxgl4t3fll3efksmbj7v/app.bsky.feed.post/3mtkba2q4tc2y",
|
||||||
|
"author": {
|
||||||
|
"handle": "cageyratfish.bsky.social",
|
||||||
|
"displayName": "cageyratfish (UTC-7) \ud83d\udc1f\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:zeytbxgl4t3fll3efksmbj7v/bafkreihef5m2gi337frbs6oo6ktpcvofn4u7bbt6w3bzyva37t3d6wq6vq"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T22:11:09.193Z",
|
||||||
|
"text": "not sure if you've addressed this, but when someone is posting a long thread (say, photos from a trip, or a multi-post essay etc) the thread will break when someone else replies to one of the posts in the OP's thread, & it's kind of a PITA to go back & click around to read the whole original thread"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:xosycr2qouakkyqdw2gaj73l/app.bsky.feed.post/3mtlj5ww23a26",
|
||||||
|
"author": {
|
||||||
|
"handle": "announcer-stef.bsky.social",
|
||||||
|
"displayName": "Stef",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:xosycr2qouakkyqdw2gaj73l/bafkreiegrnevysrpkvpuiyjgx5klywt7p3k3qec7lczx2udkid6tayrnam"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-21T10:05:47.757Z",
|
||||||
|
"text": "PLEASE can we have a landscape format that works on tablets, iPads, etc. mine looks like this. X way ahead of you on this. Thanks "
|
||||||
|
},
|
||||||
|
"embed": {
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"thumb": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:xosycr2qouakkyqdw2gaj73l/bafkreidoxfy7a22jhag75mebsrpepmyjqg62igdu27x2unracm2r27xtyy",
|
||||||
|
"fullsize": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:xosycr2qouakkyqdw2gaj73l/bafkreidoxfy7a22jhag75mebsrpepmyjqg62igdu27x2unracm2r27xtyy",
|
||||||
|
"alt": "",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 1396,
|
||||||
|
"width": 2000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"$type": "app.bsky.embed.images#view"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:d3hjgygqxvboxvfr4kdr47tx/app.bsky.feed.post/3mtknbgx57k27",
|
||||||
|
"author": {
|
||||||
|
"handle": "frozenkoi.bsky.social",
|
||||||
|
"displayName": "DK, Carb Cat Herder",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:d3hjgygqxvboxvfr4kdr47tx/bafkreiemc7lvmziylyqbab5wjq44vq5r5dmbr7nlpiqsfdjyzqcxr2lhru"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-21T01:46:40.462Z",
|
||||||
|
"text": "Can the \"xyz account followed you\" notification be improved so that it can show if that account had recently followed you, then unfollowed, then followed again (maybe show a counter or label)? To help weed out engagement farmers."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:avvpb4ypfttlslmobsjqt7qq/app.bsky.feed.post/3mtlc3dwcuc23",
|
||||||
|
"author": {
|
||||||
|
"handle": "naarna.eurosky.social",
|
||||||
|
"displayName": "naarna (she/they)",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:avvpb4ypfttlslmobsjqt7qq/bafkreicfbuioo4uktbmrje3j3tga4h65vm32nzmipu6ql3pynxualxnebm"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-21T07:59:04.542Z",
|
||||||
|
"text": "The \"mutuals of a new follow\" is almost my favorite part of it \u2764\ufe0f \n\nIs it possible to add/keep the info when they followed from a starterpack, like it is with the current layout?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:vgqkg3qfrcphyv6c7mfqqtao/app.bsky.feed.post/3mtkzbnw6zc2n",
|
||||||
|
"author": {
|
||||||
|
"handle": "qtea.me",
|
||||||
|
"displayName": "un(tea)bable \u2728",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:vgqkg3qfrcphyv6c7mfqqtao/bafkreibrurq343pl46w6ebknc4pur7q6nu76tffzjcc64s5tqht2epwxky"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-21T05:21:32.673Z",
|
||||||
|
"text": "Having the interaction buttons look different from the normal timeline feels a bit weird, I think mentions should still display the same as any other post. \nWould it make more sense to change the normal \"number of replies\" icon on each post to display differently if you replied to it?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:nejscld5zlavytijxz27qhvj/app.bsky.feed.post/3mtkalmpf432j",
|
||||||
|
"author": {
|
||||||
|
"handle": "marypcbuk.bsky.social",
|
||||||
|
"displayName": "Mary Branscombe",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:nejscld5zlavytijxz27qhvj/bafkreid62dpuwwlmnj2tqgbhpsnsmvchj6tfcetdqm2wu55zbuwm7zpacu"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T21:59:43.401Z",
|
||||||
|
"text": "can the today/earlier things be either optional o collapsible or even filterable? if they're going to take up so much space I'd like them to either earn their keep by being more useful or be something I can turn off. I don't really care if an unread @ is before or after midnight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replies": [
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:nejscld5zlavytijxz27qhvj/app.bsky.feed.post/3mtkan4bdat2j",
|
||||||
|
"author": {
|
||||||
|
"handle": "marypcbuk.bsky.social",
|
||||||
|
"displayName": "Mary Branscombe",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:nejscld5zlavytijxz27qhvj/bafkreid62dpuwwlmnj2tqgbhpsnsmvchj6tfcetdqm2wu55zbuwm7zpacu"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T22:00:33.272Z",
|
||||||
|
"text": "but if I can use them to filter to replies from the last time I was in Amsterdam by picking a date span, they could be way more useful"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:uax67vh5253v2aszwz6xaj5t/app.bsky.feed.post/3mtkgmbau7s26",
|
||||||
|
"author": {
|
||||||
|
"handle": "cantras.bsky.social",
|
||||||
|
"displayName": "Cantras",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:uax67vh5253v2aszwz6xaj5t/bafkreieobaep6sevdkisnhbp4wkizybpo7uydbfj2vyseewmnf7trld524"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T23:47:27.395Z",
|
||||||
|
"text": "How is \"Notifications -> Activity\" different from the default \"Following\" feed?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:nvfznp5ls3wcf6aktlfkfkp3/app.bsky.feed.post/3mtka7rsc6c2b",
|
||||||
|
"author": {
|
||||||
|
"handle": "erinbiba.bsky.social",
|
||||||
|
"displayName": "Erin Biba",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:nvfznp5ls3wcf6aktlfkfkp3/bafkreie2rvq65x3nzicc4gxpiglwsr5bj2dztvrczenm62mast4cclmsgi"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T21:53:06.083Z",
|
||||||
|
"text": "So replies mentions and quotes is from everyone. But there\u2019s no option to isolate replies mentions and quotes from reposts and likes from mutuals and people you follow? Just trying to understand because this is very interesting but it\u2019s A LOT."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:yczfsnwl2wfh65qvjjbesm53/app.bsky.feed.post/3mtkeo4irsc2y",
|
||||||
|
"author": {
|
||||||
|
"handle": "dd9000.bsky.social",
|
||||||
|
"displayName": "ddouglas \ud83d\udc8e \ud83c\uddfa\ud83c\udde6 \ud83e\udd8b ",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:yczfsnwl2wfh65qvjjbesm53/bafkreicc6ghxs4qqdng7msddjk63t5pnrf36l5iqjaxmgwjk46ykbdimqm"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T23:12:42.035Z",
|
||||||
|
"text": "My biggest problem is the \"30\" limit. I've set notifications \"on\" for the follows who are most important to me but, repeatedly, I'll ask myself what happened to X, check X's profile, been posting almost every day. OTOH, once I've clicked thru, I'd like it to disappear from the list."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:zxwfz6clggogwwj4j5uk3yqx/app.bsky.feed.post/3mtkmvwlvsk2i",
|
||||||
|
"author": {
|
||||||
|
"handle": "elmota.bsky.social",
|
||||||
|
"displayName": "Ayyash",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:zxwfz6clggogwwj4j5uk3yqx/bafkreib3b7qqynb2ehteq3fg2mvvsc5uzgaulqg4gujf2r24s57o4fhp7y"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-21T01:40:14.207Z",
|
||||||
|
"text": "Really? One of the most visited? I wonder \ud83e\udd14"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"post": {
|
||||||
|
"uri": "at://did:plc:zlwrdfy5dx7sdkelp2jnd7rm/app.bsky.feed.post/3mtkh6b3re22w",
|
||||||
|
"author": {
|
||||||
|
"handle": "skeeziks.bsky.social",
|
||||||
|
"displayName": "debbi\u00a9... \ud83d\udcfa\ud83d\udc40",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:zlwrdfy5dx7sdkelp2jnd7rm/bafkreictluwq7k5yi7a4igqpgcmg5y37tg5h6kf3aubs324r45bg7r2v7m"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"createdAt": "2026-08-20T23:57:31.209Z",
|
||||||
|
"text": "Show us notifications from muted accounts, please. Just because I don\u2019t want to see someone\u2019s political posts doesn\u2019t mean I don\u2019t want to see if they comment on my posts."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+541
@@ -0,0 +1,541 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t3",
|
||||||
|
"data": {
|
||||||
|
"title": "Spay day kinda nervy",
|
||||||
|
"author": "Background_Round_853",
|
||||||
|
"subreddit_name_prefixed": "r/aww",
|
||||||
|
"created_utc": 1786959785,
|
||||||
|
"selftext": "",
|
||||||
|
"permalink": "/r/aww/comments/1vqnp2e/spay_day_kinda_nervy/",
|
||||||
|
"url": "https://www.reddit.com/gallery/1vqnp2e",
|
||||||
|
"url_overridden_by_dest": "https://www.reddit.com/gallery/1vqnp2e",
|
||||||
|
"domain": "reddit.com",
|
||||||
|
"is_self": false,
|
||||||
|
"is_video": false,
|
||||||
|
"is_gallery": true,
|
||||||
|
"over_18": false,
|
||||||
|
"spoiler": false,
|
||||||
|
"num_comments": 443,
|
||||||
|
"score": 40774,
|
||||||
|
"gallery_data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"is_deleted": false,
|
||||||
|
"media_id": "6xfm6s4wowjh1",
|
||||||
|
"id": 1020169008
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"is_deleted": false,
|
||||||
|
"media_id": "x1o3cr4wowjh1",
|
||||||
|
"id": 1020169009
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"is_deleted": false,
|
||||||
|
"media_id": "kury9t4wowjh1",
|
||||||
|
"id": 1020169010
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"is_deleted": false,
|
||||||
|
"media_id": "v07ubs4wowjh1",
|
||||||
|
"id": 1020169011
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"media_metadata": {
|
||||||
|
"v07ubs4wowjh1": {
|
||||||
|
"status": "valid",
|
||||||
|
"e": "Image",
|
||||||
|
"m": "image/jpg",
|
||||||
|
"p": [
|
||||||
|
{
|
||||||
|
"y": 155,
|
||||||
|
"x": 108,
|
||||||
|
"u": "https://preview.redd.it/v07ubs4wowjh1.jpg?width=108&crop=smart&auto=webp&s=aa2a1ab3c453178f9ba6b8a06800c0fa923ad570"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 311,
|
||||||
|
"x": 216,
|
||||||
|
"u": "https://preview.redd.it/v07ubs4wowjh1.jpg?width=216&crop=smart&auto=webp&s=ec1e4d46b41511ffb3c27e2e331d23469c6be827"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 461,
|
||||||
|
"x": 320,
|
||||||
|
"u": "https://preview.redd.it/v07ubs4wowjh1.jpg?width=320&crop=smart&auto=webp&s=22812e3829549a461bdbcb81232a8edf752cac10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 922,
|
||||||
|
"x": 640,
|
||||||
|
"u": "https://preview.redd.it/v07ubs4wowjh1.jpg?width=640&crop=smart&auto=webp&s=56315dc4cf19d12e6f19401792a059f70314ef04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 1383,
|
||||||
|
"x": 960,
|
||||||
|
"u": "https://preview.redd.it/v07ubs4wowjh1.jpg?width=960&crop=smart&auto=webp&s=b706a7d2164462251f869651679c3635e15b6d25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 1555,
|
||||||
|
"x": 1080,
|
||||||
|
"u": "https://preview.redd.it/v07ubs4wowjh1.jpg?width=1080&crop=smart&auto=webp&s=e5af0cc235a7b2583d8e8a87f7799607e41f1f23"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"s": {
|
||||||
|
"y": 1736,
|
||||||
|
"x": 1205,
|
||||||
|
"u": "https://preview.redd.it/v07ubs4wowjh1.jpg?width=1205&format=pjpg&auto=webp&s=d5f2f69da610bf9a9b5b7cb99f94c21d21d4e75e"
|
||||||
|
},
|
||||||
|
"id": "v07ubs4wowjh1"
|
||||||
|
},
|
||||||
|
"6xfm6s4wowjh1": {
|
||||||
|
"status": "valid",
|
||||||
|
"e": "Image",
|
||||||
|
"m": "image/jpg",
|
||||||
|
"p": [
|
||||||
|
{
|
||||||
|
"y": 140,
|
||||||
|
"x": 108,
|
||||||
|
"u": "https://preview.redd.it/6xfm6s4wowjh1.jpg?width=108&crop=smart&auto=webp&s=231c0253d9ffcadcfa085c55264f968e1be7cd38"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 281,
|
||||||
|
"x": 216,
|
||||||
|
"u": "https://preview.redd.it/6xfm6s4wowjh1.jpg?width=216&crop=smart&auto=webp&s=efb67b41fa2afbde45b4fbf3ff389cb7c98cbe62"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 417,
|
||||||
|
"x": 320,
|
||||||
|
"u": "https://preview.redd.it/6xfm6s4wowjh1.jpg?width=320&crop=smart&auto=webp&s=4709db023af013805c8a0e4d64fdde867900df2c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 834,
|
||||||
|
"x": 640,
|
||||||
|
"u": "https://preview.redd.it/6xfm6s4wowjh1.jpg?width=640&crop=smart&auto=webp&s=2a43ebc507dc45cf578f7e74b62b6d37861d4f18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 1252,
|
||||||
|
"x": 960,
|
||||||
|
"u": "https://preview.redd.it/6xfm6s4wowjh1.jpg?width=960&crop=smart&auto=webp&s=f8f82d7392e30f1fb0d449489972ee2cd2e77d1f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 1408,
|
||||||
|
"x": 1080,
|
||||||
|
"u": "https://preview.redd.it/6xfm6s4wowjh1.jpg?width=1080&crop=smart&auto=webp&s=d438c9c25573da6b99ec89d26b373cf676d0a727"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"s": {
|
||||||
|
"y": 1572,
|
||||||
|
"x": 1205,
|
||||||
|
"u": "https://preview.redd.it/6xfm6s4wowjh1.jpg?width=1205&format=pjpg&auto=webp&s=ce8cff5c5c1d347a25c3c4431290fa76637f85b1"
|
||||||
|
},
|
||||||
|
"id": "6xfm6s4wowjh1"
|
||||||
|
},
|
||||||
|
"kury9t4wowjh1": {
|
||||||
|
"status": "valid",
|
||||||
|
"e": "Image",
|
||||||
|
"m": "image/jpg",
|
||||||
|
"p": [
|
||||||
|
{
|
||||||
|
"y": 141,
|
||||||
|
"x": 108,
|
||||||
|
"u": "https://preview.redd.it/kury9t4wowjh1.jpg?width=108&crop=smart&auto=webp&s=cca8d53fa39835bc14cfcb0b70f2409d2c0b41fd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 283,
|
||||||
|
"x": 216,
|
||||||
|
"u": "https://preview.redd.it/kury9t4wowjh1.jpg?width=216&crop=smart&auto=webp&s=5c196d8c1c5395b4388548729d779a74d011a6a7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 420,
|
||||||
|
"x": 320,
|
||||||
|
"u": "https://preview.redd.it/kury9t4wowjh1.jpg?width=320&crop=smart&auto=webp&s=6760b50b63edbf681e13739bbccc17551dbcacba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 840,
|
||||||
|
"x": 640,
|
||||||
|
"u": "https://preview.redd.it/kury9t4wowjh1.jpg?width=640&crop=smart&auto=webp&s=4cf3bb5bd6c53e6d9a8fff6560258da7fb9de4e1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 1261,
|
||||||
|
"x": 960,
|
||||||
|
"u": "https://preview.redd.it/kury9t4wowjh1.jpg?width=960&crop=smart&auto=webp&s=7c214c8d1a7a951b891b08d43cf445c28cb6ac20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 1418,
|
||||||
|
"x": 1080,
|
||||||
|
"u": "https://preview.redd.it/kury9t4wowjh1.jpg?width=1080&crop=smart&auto=webp&s=4eca14b69a68d05f78a5c6319867b4102fa3abfb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"s": {
|
||||||
|
"y": 1583,
|
||||||
|
"x": 1205,
|
||||||
|
"u": "https://preview.redd.it/kury9t4wowjh1.jpg?width=1205&format=pjpg&auto=webp&s=24c97654ddd1d3bee61788a965223a9e40d92d6e"
|
||||||
|
},
|
||||||
|
"id": "kury9t4wowjh1"
|
||||||
|
},
|
||||||
|
"x1o3cr4wowjh1": {
|
||||||
|
"status": "valid",
|
||||||
|
"e": "Image",
|
||||||
|
"m": "image/jpg",
|
||||||
|
"p": [
|
||||||
|
{
|
||||||
|
"y": 80,
|
||||||
|
"x": 108,
|
||||||
|
"u": "https://preview.redd.it/x1o3cr4wowjh1.jpg?width=108&crop=smart&auto=webp&s=8a3211796cdf827ccdc39d76ff9aab299c4cbe2f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 160,
|
||||||
|
"x": 216,
|
||||||
|
"u": "https://preview.redd.it/x1o3cr4wowjh1.jpg?width=216&crop=smart&auto=webp&s=52c19a8c35965fccd08e4aaa7bb22f9aae22549e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 237,
|
||||||
|
"x": 320,
|
||||||
|
"u": "https://preview.redd.it/x1o3cr4wowjh1.jpg?width=320&crop=smart&auto=webp&s=c54e14e1989b0c1a67708fda3fe611da15f52414"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 474,
|
||||||
|
"x": 640,
|
||||||
|
"u": "https://preview.redd.it/x1o3cr4wowjh1.jpg?width=640&crop=smart&auto=webp&s=4b7d4b60bb388c485024efe867466f6e5aba6fad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 711,
|
||||||
|
"x": 960,
|
||||||
|
"u": "https://preview.redd.it/x1o3cr4wowjh1.jpg?width=960&crop=smart&auto=webp&s=b0e46c04be74d31c5b00c0a704638888a4ca6781"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"y": 800,
|
||||||
|
"x": 1080,
|
||||||
|
"u": "https://preview.redd.it/x1o3cr4wowjh1.jpg?width=1080&crop=smart&auto=webp&s=c9bb5e091d769c8ec1360a83b6164117406641c8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"s": {
|
||||||
|
"y": 893,
|
||||||
|
"x": 1205,
|
||||||
|
"u": "https://preview.redd.it/x1o3cr4wowjh1.jpg?width=1205&format=pjpg&auto=webp&s=20fbd865e429feb527bddb0b7f4bed3fc3281075"
|
||||||
|
},
|
||||||
|
"id": "x1o3cr4wowjh1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Background_Round_853",
|
||||||
|
"body": "https://preview.redd.it/d1m4kf45pwjh1.jpeg?width=1206&format=pjpg&auto=webp&s=6ccd095185d203c6569514e4b0bf93ed9de3ed2c\n\nDone ✅✅ she looks DEVASTATED",
|
||||||
|
"created_utc": 1786959888,
|
||||||
|
"score": 6583,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": true,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Fritzkreig",
|
||||||
|
"body": "Ah, may your lady and you share mutual joy for the years to come!",
|
||||||
|
"created_utc": 1786960145,
|
||||||
|
"score": 943,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "malk600",
|
||||||
|
"body": "She's coming off anaesthesia, idk what the SOC is in your country, but kitteh would typically get dexmedetomidine + opioid + something like ketamine. Vets will typically revert dexmedetomidine w/ atipamezol, which is about as mild and pleasant as being woken up by a swift kick in the nuts. The cat is just tired, confused, restless (it's possible she's going to be woozy and tired as hell but won't be able to sleep) and overall feels like a person would feel after doing a heavy night of things that are supposed to stay in Vegas.\n\nTake good care of her!",
|
||||||
|
"created_utc": 1786961915,
|
||||||
|
"score": 1298,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "teadazed",
|
||||||
|
"body": "Yes, I showed up to collect my two (brother and sister cats) and commented on their weirdass pupils. The nurse said fondly \"yeah... that's from all the drugs we've given them\".\nThey were both batting invisible butterflies the rest of the evening.",
|
||||||
|
"created_utc": 1786966670,
|
||||||
|
"score": 449,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "rocket_randall",
|
||||||
|
"body": "It's been a while since we had ours fixed, but we opted for a slow release pain killer that would keep them comfy for ~3 days after surgery while they healed. Of course our lunatics decided that once they were home and feeling no pain that it was time to lose their minds and nothing on earth could stop them running around and crashing into things. There was a bit of a scare after our little guy crashed into something and our daughter saw what looked like an eye injury, so off we went to the emergency vet just to be told that there was no injury, it was just the lubricant that was put on his eyes before surgery breaking up and he was fine. As a small consolation the vet said he was a huge endorphin hit for the staff at that time of night.",
|
||||||
|
"created_utc": 1786983276,
|
||||||
|
"score": 28,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "teadazed",
|
||||||
|
"body": "Oh of course they must have eye drops during surgery, their eyes stay open don't they? It's the right way round to take them in for something that turns out to be nothing.\n\nIt's funny, I also have a collie who unlike the cats did not enjoy the aftermath of her neutering drugs At All, she was a haunted-looking cuddlebug until she could move properly again.",
|
||||||
|
"created_utc": 1786984556,
|
||||||
|
"score": 18,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 1,
|
||||||
|
"id": "p4dtjha"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Ashkir",
|
||||||
|
"body": "Some of the funniest things my dog has ever done was after they were waking up form anesthesia. She decided on a new place to potty that day too and to this day, years later, I can’t train that spot out of her too 👀😭😂",
|
||||||
|
"created_utc": 1786975001,
|
||||||
|
"score": 112,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Winter_Fall_7066",
|
||||||
|
"body": "My dog was given a small sedative so the vet could witness a limp (he was excited and all over the place despite being in pain). Got home, ex asked “is the dog fucked up?” Dog immediately walks over to Christmas tree and pees on it. ",
|
||||||
|
"created_utc": 1786986392,
|
||||||
|
"score": 73,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Constant_Function238",
|
||||||
|
"body": "In his brain”thanks family for bringing my potty inside.”",
|
||||||
|
"created_utc": 1786990338,
|
||||||
|
"score": 28,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Pr0digy_",
|
||||||
|
"body": "My friend years ago dropped off his mastiff to get neutered and I was the one to pick him up because my friend was working late, he was dopey and groggy but really happy to see me. I took him home and watched him until my friend came home as soon as my friend entered the house the dog growled (nothing crazy) and went and hid behind me it was hilarious. Everything was forgiven with some treats and pets. ",
|
||||||
|
"created_utc": 1787001797,
|
||||||
|
"score": 19,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "werewooferer",
|
||||||
|
"body": "the dog knew who took him, and knew who saved him 🤣",
|
||||||
|
"created_utc": 1787167461,
|
||||||
|
"score": 3,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 1,
|
||||||
|
"id": "p4c9qw2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "chickens_for_laughs",
|
||||||
|
"body": "I remember that vet telling me to not let her jump onto furniture. Like I could get her to stop. She healed fine and lived to 20!",
|
||||||
|
"created_utc": 1786979935,
|
||||||
|
"score": 22,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "cAt_S0fa",
|
||||||
|
"body": "Lol yes the vet said the same thing to my mother when she got my childhood cat spayed. That night the cat was climbing the curtains...\n",
|
||||||
|
"created_utc": 1787000485,
|
||||||
|
"score": 3,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 21,
|
||||||
|
"id": "p49jamx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 24,
|
||||||
|
"id": "p4cqjj6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Shailo",
|
||||||
|
"body": "What a precious baby 🥺 those eyes omg. Wishing her a speedy recovery and lots of cuddles and treats in the meantime 💖",
|
||||||
|
"created_utc": 1786960262,
|
||||||
|
"score": 667,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Trenzane",
|
||||||
|
"body": "I misread it as Spa day and was like ❤️ \n\nThen my brain worked and went ☹️",
|
||||||
|
"created_utc": 1786962348,
|
||||||
|
"score": 875,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "klgall1",
|
||||||
|
"body": "Glad I'm not the only one! I was reading the comments and saw the discussions about all the drugs and was very confused.\n\nI am not awake yet haha.",
|
||||||
|
"created_utc": 1786969784,
|
||||||
|
"score": 89,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Cyber_Mermaid",
|
||||||
|
"body": "I still thought this said \"spa day\" until I read this comment lol",
|
||||||
|
"created_utc": 1786985184,
|
||||||
|
"score": 23,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 8,
|
||||||
|
"id": "p48kfhn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 195,
|
||||||
|
"id": "p4c6cph"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
Vendored
+102
@@ -0,0 +1,102 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t3",
|
||||||
|
"data": {
|
||||||
|
"title": "Ratko Mladic, Murderous ‘Butcher of Bosnia,’ Dies at 83",
|
||||||
|
"author": "Knightro829",
|
||||||
|
"subreddit_name_prefixed": "r/news",
|
||||||
|
"created_utc": 1787836039,
|
||||||
|
"selftext": "",
|
||||||
|
"permalink": "/r/news/comments/1vzthbh/ratko_mladic_murderous_butcher_of_bosnia_dies_at/",
|
||||||
|
"url": "https://www.nytimes.com/2026/08/27/world/europe/ratko-mladic-dead.html?unlocked_article_code=1.8lA.Wgnd.qCJl7Bw9IdHl&smid=nytcore-ios-share",
|
||||||
|
"url_overridden_by_dest": "https://www.nytimes.com/2026/08/27/world/europe/ratko-mladic-dead.html?unlocked_article_code=1.8lA.Wgnd.qCJl7Bw9IdHl&smid=nytcore-ios-share",
|
||||||
|
"domain": "nytimes.com",
|
||||||
|
"is_self": false,
|
||||||
|
"is_video": false,
|
||||||
|
"over_18": false,
|
||||||
|
"spoiler": false,
|
||||||
|
"num_comments": 32,
|
||||||
|
"score": 187
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Knightro829",
|
||||||
|
"body": "Well, at least Death is getting closer to the mark after the past couple of days…. \n \nEdit: If you have several hours on your hands, go watch a.) The Death of Yugoslavia, a six-part documentary that aired on BBC in 1995-1996 (all episodes available on YouTube), and b.) PBS Frontline’s “The Trial of Ratko Mladic”, also available on YouTube.",
|
||||||
|
"created_utc": 1787836109,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": true,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "HungryCurrency8481",
|
||||||
|
"body": "Dolly Parton, Tim Curry, Ratko Mladic - what a trio ",
|
||||||
|
"created_utc": 1787836074,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "kataiga",
|
||||||
|
"body": "Unfortunately death also got Yayoi Kusama last night…",
|
||||||
|
"created_utc": 1787836540,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "RhoOfFeh",
|
||||||
|
"body": "He wasn't 80. I am holding out for completion of this deal.",
|
||||||
|
"created_utc": 1787836843,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "SheikhMahdeek",
|
||||||
|
"body": "See? Bad people live to ripe old age too",
|
||||||
|
"created_utc": 1787836434,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
Vendored
+351
@@ -0,0 +1,351 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t3",
|
||||||
|
"data": {
|
||||||
|
"title": "What's a healthy food that pleases the taste buds too?",
|
||||||
|
"author": "Weary_Discipline_178",
|
||||||
|
"subreddit_name_prefixed": "r/AskReddit",
|
||||||
|
"created_utc": 1787833594,
|
||||||
|
"selftext": "",
|
||||||
|
"permalink": "/r/AskReddit/comments/1vzsjee/whats_a_healthy_food_that_pleases_the_taste_buds/",
|
||||||
|
"url": "https://www.reddit.com/r/AskReddit/comments/1vzsjee/whats_a_healthy_food_that_pleases_the_taste_buds/",
|
||||||
|
"domain": "self.AskReddit",
|
||||||
|
"is_self": true,
|
||||||
|
"is_video": false,
|
||||||
|
"over_18": false,
|
||||||
|
"spoiler": false,
|
||||||
|
"num_comments": 127,
|
||||||
|
"score": 94
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "manny221122",
|
||||||
|
"body": "greek yogurt with berries, honey and a little granola 😭 it tastes suspiciously close to dessert and somehow i get to pretend i have my life together while eating it",
|
||||||
|
"created_utc": 1787835955,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 1,
|
||||||
|
"id": "p67it4u"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "CoconutxKitten",
|
||||||
|
"body": "The vast majority of fruit ",
|
||||||
|
"created_utc": 1787833703,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Technical_Buy_8198",
|
||||||
|
"body": "Currently cant get enough of a crunchy green grape. Almost ate an entire pack but had to stop myself. So good",
|
||||||
|
"created_utc": 1787833833,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "CoconutxKitten",
|
||||||
|
"body": "I love when they’re crunchy & slightly tart ",
|
||||||
|
"created_utc": 1787834158,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Ok_Reference_4860",
|
||||||
|
"body": "Yeppp and so delicious ",
|
||||||
|
"created_utc": 1787835229,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "deerhuntingdude",
|
||||||
|
"body": "Grapes are pretty low in fiber but high in sugar. They're good for you, but definitely only in moderation ",
|
||||||
|
"created_utc": 1787834561,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "random314",
|
||||||
|
"body": "Mangoes specifically.\n\nAlso watermelon.",
|
||||||
|
"created_utc": 1787834693,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "CoconutxKitten",
|
||||||
|
"body": "Watermelon is unhealthy specifically for me because I’m allergic 😂",
|
||||||
|
"created_utc": 1787834743,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 2,
|
||||||
|
"id": "p67dyiw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "AngryBarista",
|
||||||
|
"body": "Chili",
|
||||||
|
"created_utc": 1787833749,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "VillagePlayful416",
|
||||||
|
"body": "good chili is basically a cheat code, you can dump a ton of veg in there and it still tastes like a proper meal\n\n \ni slow cook mine with black beans, capsicum, shredded carrot and sometimes a bit of sweet potato, the smoked paprika does all the heavy lifting\n\n \nmake a huge batch sunday and you're set for the week",
|
||||||
|
"created_utc": 1787833937,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "NOT_Frank_or_Joe",
|
||||||
|
"body": "A winter staple for me. I also add beets, cut them up small and cook fully on the side then in to simmer with the rest. \n\nEdit: forgot to add, replace the beef with ground turkey as well.",
|
||||||
|
"created_utc": 1787834097,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "AngryBarista",
|
||||||
|
"body": "Every time I make it, i look at my wife and say \"this has no business being as good as it is for how healthy it is. Just packed with protein and fiber and can have minimal fat if you use a lean protein",
|
||||||
|
"created_utc": 1787834149,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Klutzy-Client",
|
||||||
|
"body": "But triple the amount of beans in it and we are talking",
|
||||||
|
"created_utc": 1787836492,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "kitty_swankiss",
|
||||||
|
"body": "Everything Nuts in moderation 🥜🌰",
|
||||||
|
"created_utc": 1787833760,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "jefe_gonna_jefe",
|
||||||
|
"body": "Not me.",
|
||||||
|
"created_utc": 1787833889,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "BlueEyedMalachi",
|
||||||
|
"body": "Nice",
|
||||||
|
"created_utc": 1787833961,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Lettuce_Milk",
|
||||||
|
"body": "Either you got all the nut or none at all 😎",
|
||||||
|
"created_utc": 1787836058,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 2,
|
||||||
|
"id": "p67a17t"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Chicken-picante",
|
||||||
|
"body": "What about odyssey?\n\nOdyssey deez nutz",
|
||||||
|
"created_utc": 1787834927,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Perfectenschlag_",
|
||||||
|
"body": "Boooooo",
|
||||||
|
"created_utc": 1787836540,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": true,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 10,
|
||||||
|
"id": "p677sc7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
Vendored
+451
@@ -0,0 +1,451 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t3",
|
||||||
|
"data": {
|
||||||
|
"title": "1957 Ford Fairlane",
|
||||||
|
"author": "icleanjaxfl",
|
||||||
|
"subreddit_name_prefixed": "r/oddlysatisfying",
|
||||||
|
"created_utc": 1787830536,
|
||||||
|
"selftext": "",
|
||||||
|
"permalink": "/r/oddlysatisfying/comments/1vzrfkw/1957_ford_fairlane/",
|
||||||
|
"url": "https://v.redd.it/74hudhx2mwlh1",
|
||||||
|
"url_overridden_by_dest": "https://v.redd.it/74hudhx2mwlh1",
|
||||||
|
"domain": "v.redd.it",
|
||||||
|
"post_hint": "hosted:video",
|
||||||
|
"is_self": false,
|
||||||
|
"is_video": true,
|
||||||
|
"over_18": false,
|
||||||
|
"spoiler": false,
|
||||||
|
"num_comments": 109,
|
||||||
|
"score": 3477,
|
||||||
|
"secure_media": {
|
||||||
|
"reddit_video": {
|
||||||
|
"bitrate_kbps": 2400,
|
||||||
|
"fallback_url": "https://v.redd.it/74hudhx2mwlh1/CMAF_720.mp4?source=fallback",
|
||||||
|
"has_audio": false,
|
||||||
|
"height": 1280,
|
||||||
|
"width": 720,
|
||||||
|
"scrubber_media_url": "https://v.redd.it/74hudhx2mwlh1/CMAF_96.mp4",
|
||||||
|
"dash_url": "https://v.redd.it/74hudhx2mwlh1/DASHPlaylist.mpd?a=1790429193%2CNjQwMTBmMDMxODhjMGViOGUyMjU2ZDE2NGFkNmI2MDU2ZDQ2NmVjNmEzNzg0MTQzNGVkMTU5OGI2ZTgyYTU3ZQ%3D%3D&v=1&f=sd",
|
||||||
|
"duration": 25,
|
||||||
|
"hls_url": "https://v.redd.it/74hudhx2mwlh1/HLSPlaylist.m3u8?a=1790429193%2CZjkwZmQwODJiYmRhNGY2NDkyYTMxMDZlYmVhMzQwYWVkYTg0MzMwOWEwYjAyMGZhMzY0MWJmYTIyMmE0OTQ3Zg%3D%3D&v=1&f=sd",
|
||||||
|
"is_gif": false,
|
||||||
|
"transcoding_status": "completed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"media": {
|
||||||
|
"reddit_video": {
|
||||||
|
"bitrate_kbps": 2400,
|
||||||
|
"fallback_url": "https://v.redd.it/74hudhx2mwlh1/CMAF_720.mp4?source=fallback",
|
||||||
|
"has_audio": false,
|
||||||
|
"height": 1280,
|
||||||
|
"width": 720,
|
||||||
|
"scrubber_media_url": "https://v.redd.it/74hudhx2mwlh1/CMAF_96.mp4",
|
||||||
|
"dash_url": "https://v.redd.it/74hudhx2mwlh1/DASHPlaylist.mpd?a=1790429193%2CNjQwMTBmMDMxODhjMGViOGUyMjU2ZDE2NGFkNmI2MDU2ZDQ2NmVjNmEzNzg0MTQzNGVkMTU5OGI2ZTgyYTU3ZQ%3D%3D&v=1&f=sd",
|
||||||
|
"duration": 25,
|
||||||
|
"hls_url": "https://v.redd.it/74hudhx2mwlh1/HLSPlaylist.m3u8?a=1790429193%2CZjkwZmQwODJiYmRhNGY2NDkyYTMxMDZlYmVhMzQwYWVkYTg0MzMwOWEwYjAyMGZhMzY0MWJmYTIyMmE0OTQ3Zg%3D%3D&v=1&f=sd",
|
||||||
|
"is_gif": false,
|
||||||
|
"transcoding_status": "completed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"source": {
|
||||||
|
"url": "https://external-preview.redd.it/N245aGFidjJtd2xoMRGGAvg1khFB1AQL8vVunwC_Mb5qnXHCQGxm0j4C5bdR.png?format=pjpg&auto=webp&s=cd60e451633a907b172389c0cd1c09e174ac9f16",
|
||||||
|
"width": 405,
|
||||||
|
"height": 720
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Head-State-",
|
||||||
|
"body": "That retractable hardtop transformer mechanism is pure engineering wizardry.",
|
||||||
|
"created_utc": 1787830965,
|
||||||
|
"score": 328,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Pretend-Internet-625",
|
||||||
|
"body": "ya until you have to go and fix it and make it work properly. ",
|
||||||
|
"created_utc": 1787832356,
|
||||||
|
"score": 150,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "bigalindahouse",
|
||||||
|
"body": "Would much rather work on this than today's vehicles",
|
||||||
|
"created_utc": 1787833724,
|
||||||
|
"score": 63,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Pretend-Internet-625",
|
||||||
|
"body": "Well ya I get that but these were a real pain in the ",
|
||||||
|
"created_utc": 1787833961,
|
||||||
|
"score": 6,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Brasticus",
|
||||||
|
"body": "r/redditsniper claims another",
|
||||||
|
"created_utc": 1787834765,
|
||||||
|
"score": 21,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Andy_B_Goode",
|
||||||
|
"body": "Yeah, it's neat, but it also totally looks like one of those things that would break in the first two or three years and most people wouldn't bother fixing",
|
||||||
|
"created_utc": 1787835341,
|
||||||
|
"score": 6,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "utukore",
|
||||||
|
"body": "Same setup was used for the e93 roof. Other than the squeeks and rattles or the slow open/ close time they worked well.",
|
||||||
|
"created_utc": 1787836411,
|
||||||
|
"score": 3,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "kss1089",
|
||||||
|
"body": "Everything is a 4 bar mechanism. if it's super complicated, it's multiple 4 bar mechanisms. ",
|
||||||
|
"created_utc": 1787833936,
|
||||||
|
"score": 12,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Tony_Penny",
|
||||||
|
"body": "Ford Fairlane, rock and roll detective. ",
|
||||||
|
"created_utc": 1787831095,
|
||||||
|
"score": 29,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "perflubon",
|
||||||
|
"body": "One of the best \"worst\" movies ever.",
|
||||||
|
"created_utc": 1787832767,
|
||||||
|
"score": 7,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Tony_Penny",
|
||||||
|
"body": "I absolutely loved that movie as a kid. ",
|
||||||
|
"created_utc": 1787833553,
|
||||||
|
"score": 3,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "imamakebaddecisions",
|
||||||
|
"body": "Booty time, booty time, across the USA, booty time, booty time, hey hey hey!",
|
||||||
|
"created_utc": 1787833566,
|
||||||
|
"score": 5,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "[deleted]",
|
||||||
|
"body": "[removed]",
|
||||||
|
"created_utc": 1787833134,
|
||||||
|
"score": 145,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Bongressman",
|
||||||
|
"body": "And covering it for the winter... then uncovering it in the Spring. Getting that \"unwrapping a new gift\" endorphin rush all over again every year.",
|
||||||
|
"created_utc": 1787834518,
|
||||||
|
"score": 8,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "zytukin",
|
||||||
|
"body": "You'd leave it outside over winter???",
|
||||||
|
"created_utc": 1787834574,
|
||||||
|
"score": 4,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Bongressman",
|
||||||
|
"body": "I live in Seattle, so snow isnt a factor. Streets are dotted with covered cars in the winter. I guess it depends on location!",
|
||||||
|
"created_utc": 1787834801,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Flyingdutchman2305",
|
||||||
|
"body": "?? Isnt there lots of snow in seattle",
|
||||||
|
"created_utc": 1787835019,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Bongressman",
|
||||||
|
"body": "No. It doesn't snow in Seattle. We don't even have a snow removal budget in the city.\n\nWinter bottoms out at like 40-45 fahrenheit. It just mists, drizzles, gets overcast and grey for six months.",
|
||||||
|
"created_utc": 1787835128,
|
||||||
|
"score": 2,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Flyingdutchman2305",
|
||||||
|
"body": "Huh, my american Geography when it comes to latitude is apparently off\n\n",
|
||||||
|
"created_utc": 1787835286,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Bongressman",
|
||||||
|
"body": "It snows in Washington state, just not Seattle. We are surrounded by the Cascades and Olympic Mountains. Kind of boxed in and protected.\n\nOur weather closely mirrors London's, but with even less snowfall.",
|
||||||
|
"created_utc": 1787835622,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": {
|
||||||
|
"kind": "Listing",
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "Flyingdutchman2305",
|
||||||
|
"body": "Never knew, always thought it was one of those typical snowy winter cities",
|
||||||
|
"created_utc": 1787835875,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "zytukin",
|
||||||
|
"body": "It's not due to latitude, Seattle is more north than Wyoming, Kansas, etc which get a lot of snow.\n\nI think it has to do with being near the west coast so it gets hit with warm air coming up from the tropics.",
|
||||||
|
"created_utc": 1787835832,
|
||||||
|
"score": 1,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "t1",
|
||||||
|
"data": {
|
||||||
|
"author": "its_Raze_7",
|
||||||
|
"body": "bot",
|
||||||
|
"created_utc": 1787836203,
|
||||||
|
"score": 2,
|
||||||
|
"score_hidden": false,
|
||||||
|
"is_submitter": false,
|
||||||
|
"replies": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "more",
|
||||||
|
"data": {
|
||||||
|
"count": 1,
|
||||||
|
"id": "p676rzl"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
+344
@@ -0,0 +1,344 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "Dbs42N8k8uT",
|
||||||
|
"taken_at": 1786025139,
|
||||||
|
"caption": {
|
||||||
|
"text": "@nasa live is so cool! It’s so fun to see Jessica and Anil do their spacewalk .. it’s always encouraging to see women astronauts and their work▓▓▓▓▓\nMaybe @europeanspaceagency can also have some cool live about other missions or live viewing of Earth or other space related stuff."
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "arielbrunandthesea"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": null
|
||||||
|
},
|
||||||
|
"image_versions2": {
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"height": 2888,
|
||||||
|
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/764774453_17972992692121062_2185374187830722827_n.jpg?stp=cp6_dst-jpg_e35_tt6&_nc_cat=109&ig_cache_key=Mzk1Nzc4NzE0NzI4MTE0MjE1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuNDAwMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=h0gupge1KZkQ7kNvwGvrlx1&_nc_oc=Adqsqi5M7yggTFLue6KhoE9NXrDN34jg-_QhKj6e8nrCI8a1HzExKmjFNdhu1VmBtFk&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-1.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQGLlnGkPyNAps_dhwFsaPi2Ao2Y6EuWctZwRnSPgMSrAw&oe=6A9528E0",
|
||||||
|
"width": 4001
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 780,
|
||||||
|
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/764774453_17972992692121062_2185374187830722827_n.jpg?stp=cp6_dst-jpg_e35_s1080x1080_tt6&_nc_cat=109&ig_cache_key=Mzk1Nzc4NzE0NzI4MTE0MjE1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuNDAwMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=h0gupge1KZkQ7kNvwGvrlx1&_nc_oc=Adqsqi5M7yggTFLue6KhoE9NXrDN34jg-_QhKj6e8nrCI8a1HzExKmjFNdhu1VmBtFk&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-1.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQG2s0Vm7o3KIaR_nV9DofUPXeahOWJQmL25xmeFVQrhZQ&oe=6A9528E0",
|
||||||
|
"width": 1080
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DcKY3AAGVDP",
|
||||||
|
"taken_at": 1787015002,
|
||||||
|
"caption": {
|
||||||
|
"text": "Right now as you read this, two astronauts are spacewalking outside the International Space Station. \n\n@nasa astronaut Anil Menon and @europeanspaceagency astronaut Sophie Adenot are conducting Spacewalk 97 today, August 18, replacing a Space-to-Ground antenna on the International Space Station. \n\nLive coverage began at 7AM EDT on NASA+. \n\nHere's what a spacewalk actually involves that nobody tells you: \n\nThey suit up in 300-pound EMU spacesuits that are essentially personal spacecraft. 👇"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "cosmiclab.space"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": null
|
||||||
|
},
|
||||||
|
"image_versions2": {
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"height": 1402,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/778899179_18105140402113849_8885082483546796711_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=102&ig_cache_key=Mzk2NjA5MTc1NDU0MjI4OTEwMw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTEyMi5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=nvcD17NjvvoQ7kNvwFwgUux&_nc_oc=Adr7n_HeqGcl7LtOhcp_iQHCOut2OOCKH6Go0m4VFqtBNaKbE7QREb5KWeNmHt767ro&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQFdQc8Wj4gESXjji1lma5MnS2bO3M8ZC2WccZuF3-So1A&oe=6A951ED2",
|
||||||
|
"width": 1122
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 900,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/778899179_18105140402113849_8885082483546796711_n.jpg?stp=dst-jpg_e35_p720x720_tt6&_nc_cat=102&ig_cache_key=Mzk2NjA5MTc1NDU0MjI4OTEwMw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTEyMi5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=nvcD17NjvvoQ7kNvwFwgUux&_nc_oc=Adr7n_HeqGcl7LtOhcp_iQHCOut2OOCKH6Go0m4VFqtBNaKbE7QREb5KWeNmHt767ro&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQHQqpTMS28QTD5DZa7Jkc-C7jGx5P9EM7JbSasqCtlaNA&oe=6A951ED2",
|
||||||
|
"width": 720
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DceWVx-kSzb",
|
||||||
|
"taken_at": 1787684770,
|
||||||
|
"caption": {
|
||||||
|
"text": "On the Fly\n\nMission complete.\n\nSophie Adenot and Anil Menon are safely back inside the ISS after U.S. Spacewalk 98, completing work on the station’s Space-to-Ground antenna system.\n\n⏱️ Duration: 6h 30mins\n\n📸 NASA live coverage"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "marzano.malone"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": null
|
||||||
|
},
|
||||||
|
"image_versions2": {
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"height": 643,
|
||||||
|
"url": "https://scontent-lga3-2.cdninstagram.com/v/t51.82787-15/784597767_18115184995973316_2626836958628726231_n.jpg?stp=cp6_dst-jpg_e35_tt6&_nc_cat=105&ig_cache_key=Mzk3MTcwOTUxODI0OTEzOTk4Ng%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTEyMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=rh3pk2X6IJcQ7kNvwFSm8yR&_nc_oc=AdoRvUeiaPx0FLP-HYdvD1VFw3V8vhz9JBNxakBbwZtkCvAVeMF444HeHOUxBQVr6TQ&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-2.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQF5oCnKCWaNwfCG2t3O_MJmjLRmxoShpX1epNFgJ0d-Tw&oe=6A95008A",
|
||||||
|
"width": 1121
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 413,
|
||||||
|
"url": "https://scontent-lga3-2.cdninstagram.com/v/t51.82787-15/784597767_18115184995973316_2626836958628726231_n.jpg?stp=cp6_dst-jpg_e35_s720x720_tt6&_nc_cat=105&ig_cache_key=Mzk3MTcwOTUxODI0OTEzOTk4Ng%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTEyMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=rh3pk2X6IJcQ7kNvwFSm8yR&_nc_oc=AdoRvUeiaPx0FLP-HYdvD1VFw3V8vhz9JBNxakBbwZtkCvAVeMF444HeHOUxBQVr6TQ&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-2.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQFkLH93NsfFKEYwPs0lYCfKMkwivG31luMp9lOMIgp1_w&oe=6A95008A",
|
||||||
|
"width": 720
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DbsvvGxEWt6",
|
||||||
|
"taken_at": 1786020363,
|
||||||
|
"caption": {
|
||||||
|
"text": "NASA astronauts Jessica Meir and Anil Menon are stepping outside the International Space Station for a spacewalk to work on the orbiting outpost's power system and prepare for the installation of new solar arrays. Watch live: https://youtube.com/live/thfYPsRqxmw"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "spaceflightnow"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": null
|
||||||
|
},
|
||||||
|
"image_versions2": {
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"height": 917,
|
||||||
|
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/766580960_17978263872115867_5021188917983091958_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=103&ig_cache_key=Mzk1Nzc0ODExNzgxODkyNzk5NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI2My5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=7i7qcUEo9LcQ7kNvwG-2BIF&_nc_oc=AdqQIfyK9Otzo3_dU7rQ3BICwY5ItQuce0dVcmy6XIXEin5FAxjybp25tUMIXyKcIvk&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-1.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQFUoVSBvUuWYZ_4sfTpJi4TFEdESpRhfc4aeWwN9nTWlQ&oe=6A951EB5",
|
||||||
|
"width": 1263
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 784,
|
||||||
|
"url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-15/766580960_17978263872115867_5021188917983091958_n.jpg?stp=dst-jpg_e35_s1080x1080_tt6&_nc_cat=103&ig_cache_key=Mzk1Nzc0ODExNzgxODkyNzk5NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI2My5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=7i7qcUEo9LcQ7kNvwG-2BIF&_nc_oc=AdqQIfyK9Otzo3_dU7rQ3BICwY5ItQuce0dVcmy6XIXEin5FAxjybp25tUMIXyKcIvk&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-1.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQH__KSDX1NAaIRs4AAB1AY71kHGFdAHXkdwe8b4lyVwtw&oe=6A951EB5",
|
||||||
|
"width": 1080
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DcMILDEgFD0",
|
||||||
|
"taken_at": 1787073362,
|
||||||
|
"caption": {
|
||||||
|
"text": "On the Fly\n\nNASA astronaut Anil Menon and ESA astronaut Sophie Adenot are outside the International Space Station today, replacing a high-speed communications antenna.\n\nThe spacewalk is Adenot’s first — making her the first French woman to conduct an EVA.\n\n📸 NASA Live feed."
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "marzano.malone"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": null
|
||||||
|
},
|
||||||
|
"image_versions2": {
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"height": 628,
|
||||||
|
"url": "https://scontent-lga3-2.cdninstagram.com/v/t51.82787-15/778882209_18113994055973316_8456776599937454598_n.jpg?stp=cp6_dst-jpg_e35_tt6&_nc_cat=100&ig_cache_key=Mzk2NjU4MTMxNTM4OTY0MDk0OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTE3MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=HymAcb2XfLUQ7kNvwEHOgVr&_nc_oc=AdrDS6pVpFYD6bNORpGLeIakOTjX1BTxjxVB5_THJhhmP-elZXjB6h_2zZ6auBgheYo&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-2.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQELhjoCpHZzNAA_Gk3RPSNUUe1c2lhl-pi_JeW377PuDA&oe=6A9516AE",
|
||||||
|
"width": 1170
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 386,
|
||||||
|
"url": "https://scontent-lga3-2.cdninstagram.com/v/t51.82787-15/778882209_18113994055973316_8456776599937454598_n.jpg?stp=cp6_dst-jpg_e35_s720x720_tt6&_nc_cat=100&ig_cache_key=Mzk2NjU4MTMxNTM4OTY0MDk0OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTE3MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=HymAcb2XfLUQ7kNvwEHOgVr&_nc_oc=AdrDS6pVpFYD6bNORpGLeIakOTjX1BTxjxVB5_THJhhmP-elZXjB6h_2zZ6auBgheYo&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-2.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQHPYN6VEAGFfhaR0SdsQ35etsdchzIHaxEunembQl7P2g&oe=6A9516AE",
|
||||||
|
"width": 720
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DccGgKuEW-E",
|
||||||
|
"taken_at": 1787609357,
|
||||||
|
"caption": {
|
||||||
|
"text": "Home improvement looks a little different at 250 miles (400 km) above Earth.\n\nWatch live as NASA astronaut Anil Menon and @europeanspaceagency astronaut Sophie Adenot step outside the International Space Station at 7 a.m. EDT (1100 UTC) on Tuesday, Aug. 25 for their second spacewalk together, finishing installation of a new communications antenna."
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "nasa"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": null
|
||||||
|
},
|
||||||
|
"image_versions2": {
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"height": 2001,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=1&ig_cache_key=Mzk3MTA3NzU3MDI0NjU2OTg2MA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTYwMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=oqiv0FBeh0gQ7kNvwGO3kcE&_nc_oc=AdpcHKPcKduy07EfssZ4zHV4Mk8TkqtFYffmIfwL1AAZoQp_4EoX9fX0oUkamNFpFEM&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQH2smIgAIkUJ8ueESJyMPcniSpCTVDoMn6Qb_PQyuXtsg&oe=6A95173D",
|
||||||
|
"width": 1601
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 1350,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=dst-jpg_e35_p1080x1080_tt6&_nc_cat=1&ig_cache_key=Mzk3MTA3NzU3MDI0NjU2OTg2MA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTYwMS5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=oqiv0FBeh0gQ7kNvwGO3kcE&_nc_oc=AdpcHKPcKduy07EfssZ4zHV4Mk8TkqtFYffmIfwL1AAZoQp_4EoX9fX0oUkamNFpFEM&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=-7d1lORqpDaYwMOs7mOlDw&_nc_ss=7a22e&oh=00_AQGLewsERJ0a7RuQZg26IfpkCEMwXivDYaq0qZylWD03rQ&oe=6A95173D",
|
||||||
|
"width": 1080
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DccGhYSkXYY",
|
||||||
|
"taken_at": 1787609367,
|
||||||
|
"caption": {
|
||||||
|
"text": "Here's where you can watch!"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "nasa"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DcdOzzVjUWc",
|
||||||
|
"taken_at": 1787647267,
|
||||||
|
"caption": {
|
||||||
|
"text": "Teamwork 💙"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "europeanspaceagency"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DceELlFApZ1",
|
||||||
|
"taken_at": 1787675249,
|
||||||
|
"caption": {
|
||||||
|
"text": "Dual units are appropriate and appreciated. Making SI (metric) units familiar to Americans is important work. Don't look back. ✌🏼❤️🌐"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "eyeballhatred"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DccHgwHEo_S",
|
||||||
|
"taken_at": 1787609886,
|
||||||
|
"caption": {
|
||||||
|
"text": "Next level DIY"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "platypus.4604274"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DcdLgJvAGr2",
|
||||||
|
"taken_at": 1787645533,
|
||||||
|
"caption": {
|
||||||
|
"text": "Talk about a high-stakes DIY project! 🛠️ clear view, zero gravity, and a 250-mile drop if you drop your wrench. Losing a screw behind the drywall is frustrating enough on Earth—I can’t imagine managing tethered tools while working on the station's literal lifeline to Houston. Massive respect to Anil and Sophie! 🧑🚀💪"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "marzano.malone"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DccHHNXEnju",
|
||||||
|
"taken_at": 1787609677,
|
||||||
|
"caption": {
|
||||||
|
"text": "無事終わるのを祈ります💫"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "hagane_soutarou"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DcdtFOGE1q4",
|
||||||
|
"taken_at": 1787663138,
|
||||||
|
"caption": {
|
||||||
|
"text": "🚀🚀🚀🧑🚀🇯🇵"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "daoxieyukiko4"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DcdtpBbk604",
|
||||||
|
"taken_at": 1787663431,
|
||||||
|
"caption": {
|
||||||
|
"text": "🇯🇵🚀🧑🚀🧑🚀🧑🚀🚀🇯🇵"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "daoxieyukiko4"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DccR7SVjA3v",
|
||||||
|
"taken_at": 1787615346,
|
||||||
|
"caption": {
|
||||||
|
"text": "Good luck astronauts!!"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "kxtiebarber"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DccHe5OmEd3",
|
||||||
|
"taken_at": 1787609871,
|
||||||
|
"caption": {
|
||||||
|
"text": "🥴🥴🥴😒"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "pc19cp"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DcclsueCOvv",
|
||||||
|
"taken_at": 1787625713,
|
||||||
|
"caption": {
|
||||||
|
"text": "."
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "terrence_eleven"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DccKJp0k-K-",
|
||||||
|
"taken_at": 1787611270,
|
||||||
|
"caption": {
|
||||||
|
"text": "🌕✨🌏🌟💯"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": "sagarks007"
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"reply_to_author": {
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
Vendored
+128
@@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"id_str": "2086902906171605466",
|
||||||
|
"text": "NEW: Joey McGuire tells @Brett_McMurphy Texas Tech can't find future games, with Texas A&M, USC, NC State and Mississippi State saying no or canceling series❌\n\n\"We’re talking to everybody and anybody. So far, everybody’s told us no.\"\n\nhttps://t.co/rEGx9JN5pJ https://t.co/QdJhOVu4En",
|
||||||
|
"display_text_range": [
|
||||||
|
0,
|
||||||
|
262
|
||||||
|
],
|
||||||
|
"entities": {
|
||||||
|
"urls": [
|
||||||
|
{
|
||||||
|
"url": "https://t.co/rEGx9JN5pJ",
|
||||||
|
"expanded_url": "https://www.on3.com/news/joey-mcguire-on-texas-techs-villain-era-were-the-most-hated-team-in-college-football/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"created_at": "2026-08-10T19:50:00.000Z",
|
||||||
|
"user": {
|
||||||
|
"screen_name": "On3",
|
||||||
|
"name": "On3",
|
||||||
|
"profile_image_url_https": "https://pbs.twimg.com/profile_images/1414989749203390470/D2B25n0r_normal.jpg"
|
||||||
|
},
|
||||||
|
"mediaDetails": [
|
||||||
|
{
|
||||||
|
"type": "photo",
|
||||||
|
"media_url_https": "https://pbs.twimg.com/media/HPYnDNPW0AAJd6h.jpg",
|
||||||
|
"original_info": {
|
||||||
|
"focus_rects": [
|
||||||
|
{
|
||||||
|
"h": 605,
|
||||||
|
"w": 1080,
|
||||||
|
"x": 0,
|
||||||
|
"y": 609
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1080,
|
||||||
|
"w": 1080,
|
||||||
|
"x": 0,
|
||||||
|
"y": 270
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1231,
|
||||||
|
"w": 1080,
|
||||||
|
"x": 0,
|
||||||
|
"y": 119
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1350,
|
||||||
|
"w": 675,
|
||||||
|
"x": 169,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1350,
|
||||||
|
"w": 1080,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"height": 1350,
|
||||||
|
"width": 1080
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"quoted_tweet": {
|
||||||
|
"id_str": "2086832880676716740",
|
||||||
|
"text": "NEW: Texas Tech's Joey McGuire tells @Brett_McMurphy the Red Raiders are embracing the villain role🔥\n\n\"We’re the most hated team in college football. And I absolutely love it,\" McGuire told his team in a meeting.\n\nStory: https://t.co/rEGx9JN5pJ https://t.co/q7m39MOMNo",
|
||||||
|
"display_text_range": [
|
||||||
|
0,
|
||||||
|
245
|
||||||
|
],
|
||||||
|
"entities": {
|
||||||
|
"urls": [
|
||||||
|
{
|
||||||
|
"url": "https://t.co/rEGx9JN5pJ",
|
||||||
|
"expanded_url": "https://www.on3.com/news/joey-mcguire-on-texas-techs-villain-era-were-the-most-hated-team-in-college-football/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"created_at": "2026-08-10T15:11:44.000Z",
|
||||||
|
"user": {
|
||||||
|
"screen_name": "On3",
|
||||||
|
"name": "On3",
|
||||||
|
"profile_image_url_https": "https://pbs.twimg.com/profile_images/1414989749203390470/D2B25n0r_normal.jpg"
|
||||||
|
},
|
||||||
|
"mediaDetails": [
|
||||||
|
{
|
||||||
|
"type": "photo",
|
||||||
|
"media_url_https": "https://pbs.twimg.com/media/HPXqebKWkAA5gvA.jpg",
|
||||||
|
"original_info": {
|
||||||
|
"focus_rects": [
|
||||||
|
{
|
||||||
|
"h": 630,
|
||||||
|
"w": 1125,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 630,
|
||||||
|
"w": 630,
|
||||||
|
"x": 75,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 630,
|
||||||
|
"w": 553,
|
||||||
|
"x": 114,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 630,
|
||||||
|
"w": 315,
|
||||||
|
"x": 233,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 630,
|
||||||
|
"w": 1200,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"height": 630,
|
||||||
|
"width": 1200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+70
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"id_str": "2092384141488620017",
|
||||||
|
"text": "The SEC, as expected, adopts a ban on professional players returning to college, which also includes basketball.",
|
||||||
|
"display_text_range": [
|
||||||
|
0,
|
||||||
|
112
|
||||||
|
],
|
||||||
|
"created_at": "2026-08-25T22:50:28.000Z",
|
||||||
|
"user": {
|
||||||
|
"screen_name": "RossDellenger",
|
||||||
|
"name": "Ross Dellenger",
|
||||||
|
"profile_image_url_https": "https://pbs.twimg.com/profile_images/1174672001958649861/m2N_Dbim_normal.jpg"
|
||||||
|
},
|
||||||
|
"quoted_tweet": {
|
||||||
|
"id_str": "2092383884872765473",
|
||||||
|
"text": "Following meetings with the Presidents, Chancellors, and Athletics Directors of the Southeastern Conference, the SEC will enforce the following policy: https://t.co/LohWnb7G1J",
|
||||||
|
"display_text_range": [
|
||||||
|
0,
|
||||||
|
151
|
||||||
|
],
|
||||||
|
"created_at": "2026-08-25T22:49:27.000Z",
|
||||||
|
"user": {
|
||||||
|
"screen_name": "SEC",
|
||||||
|
"name": "Southeastern Conference",
|
||||||
|
"profile_image_url_https": "https://pbs.twimg.com/profile_images/1807664314687299584/qhj7cnXy_normal.jpg"
|
||||||
|
},
|
||||||
|
"mediaDetails": [
|
||||||
|
{
|
||||||
|
"type": "photo",
|
||||||
|
"media_url_https": "https://pbs.twimg.com/media/HQmj_lxW8AAlFmc.jpg",
|
||||||
|
"original_info": {
|
||||||
|
"focus_rects": [
|
||||||
|
{
|
||||||
|
"h": 605,
|
||||||
|
"w": 1080,
|
||||||
|
"x": 0,
|
||||||
|
"y": 742
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1080,
|
||||||
|
"w": 1080,
|
||||||
|
"x": 0,
|
||||||
|
"y": 360
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1231,
|
||||||
|
"w": 1080,
|
||||||
|
"x": 0,
|
||||||
|
"y": 209
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1440,
|
||||||
|
"w": 720,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1440,
|
||||||
|
"w": 1080,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"height": 1440,
|
||||||
|
"width": 1080
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+28
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id_str": "2092435281869738141",
|
||||||
|
"text": "Deacon Kevin Barron of the Catholic Community of Buffalo North led the prayer service on Tuesday for Mayich. In an interview with @azcentral, Barron said that Maywich suffered a “cardiac event” and is currently in a coma.",
|
||||||
|
"display_text_range": [
|
||||||
|
0,
|
||||||
|
221
|
||||||
|
],
|
||||||
|
"created_at": "2026-08-26T02:13:41.000Z",
|
||||||
|
"user": {
|
||||||
|
"screen_name": "MikeMcMahonCHN",
|
||||||
|
"name": "Mike McMahon",
|
||||||
|
"profile_image_url_https": "https://pbs.twimg.com/profile_images/2068376149747449856/oqDq3qs4_normal.jpg"
|
||||||
|
},
|
||||||
|
"quoted_tweet": {
|
||||||
|
"id_str": "2092384930034548928",
|
||||||
|
"text": "Matthew Mayich, a sophomore defenseman, is the player hospitalized. There was a virtual prayer service held today, shared on social media, in which Mayich’s cousin spoke. I didn’t want to share the player’s name before we were sure the player’s family was OK with it being shared.",
|
||||||
|
"display_text_range": [
|
||||||
|
0,
|
||||||
|
280
|
||||||
|
],
|
||||||
|
"created_at": "2026-08-25T22:53:36.000Z",
|
||||||
|
"user": {
|
||||||
|
"screen_name": "MikeMcMahonCHN",
|
||||||
|
"name": "Mike McMahon",
|
||||||
|
"profile_image_url_https": "https://pbs.twimg.com/profile_images/2068376149747449856/oqDq3qs4_normal.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { escapeHtml } from '../src/render/html.ts';
|
||||||
|
import { renderMarkdown } from '../src/render/markdown.ts';
|
||||||
|
|
||||||
|
const md = (text: string): string => String(renderMarkdown(text));
|
||||||
|
|
||||||
|
/** Stands in for the real one, which proxies. Escapes the way that one does:
|
||||||
|
* placing the image is the renderer's job, and so is making it safe. */
|
||||||
|
const img = (url: string, alt: string): string =>
|
||||||
|
`<img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}">`;
|
||||||
|
const mdi = (text: string): string => String(renderMarkdown(text, img));
|
||||||
|
|
||||||
|
test('markup a commenter typed is text, not markup', () => {
|
||||||
|
const out = md('<script>alert(1)</script> & "quoted"');
|
||||||
|
assert.ok(!out.includes('<script>'));
|
||||||
|
assert.match(out, /<script>/);
|
||||||
|
assert.match(out, /&/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only http and https become links', () => {
|
||||||
|
assert.match(md('[go](https://example.com/a)'), /<a href="https:\/\/example\.com\/a"/);
|
||||||
|
// The label is still shown; it is only the link that is refused.
|
||||||
|
const dangerous = md('[go](javascript:alert(1))');
|
||||||
|
assert.ok(!dangerous.includes('<a '));
|
||||||
|
assert.match(dangerous, /\[go\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bare URL is linked without swallowing the sentence it ends', () => {
|
||||||
|
const out = md('see https://example.com/x.');
|
||||||
|
assert.match(out, /href="https:\/\/example\.com\/x"/);
|
||||||
|
assert.ok(out.endsWith('.</p>'), `trailing full stop should stay outside the link: ${out}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ampersand in a link target survives as one', () => {
|
||||||
|
const out = md('[x](https://example.com/?a=1&b=2)');
|
||||||
|
assert.match(out, /href="https:\/\/example\.com\/\?a=1&b=2"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a quote is a block of its own, so the reply is not read as part of it', () => {
|
||||||
|
const out = md('> they said this\n\nand I disagree');
|
||||||
|
assert.match(out, /<blockquote><p>they said this<\/p><\/blockquote><p>and I disagree<\/p>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quoting a quote keeps both levels', () => {
|
||||||
|
assert.match(md('> > deep\n> shallow'), /<blockquote><blockquote>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emphasis does not fire inside a word', () => {
|
||||||
|
// `snake_case_names` are ordinary in the subreddits this will be pointed at.
|
||||||
|
assert.equal(md('some_variable_name'), '<p>some_variable_name</p>');
|
||||||
|
assert.match(md('_yes_'), /<em>yes<\/em>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('code is left exactly as typed', () => {
|
||||||
|
assert.match(md('`a < b && c`'), /<code>a < b && c<\/code>/);
|
||||||
|
assert.match(md('```\n<b>not bold</b>\n```'), /<pre><code><b>not bold<\/b><\/code><\/pre>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subreddit and user references link back to reddit', () => {
|
||||||
|
assert.match(md('over in r/aww'), /href="https:\/\/www\.reddit\.com\/r\/aww"/);
|
||||||
|
assert.match(md('ask u/someone'), /href="https:\/\/www\.reddit\.com\/u\/someone"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists survive, both kinds', () => {
|
||||||
|
assert.match(md('- one\n- two'), /<ul><li>one<\/li><li>two<\/li><\/ul>/);
|
||||||
|
assert.match(md('1. one\n2. two'), /<ol><li>one<\/li><li>two<\/li><\/ol>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single newline inside a paragraph is a line break, a blank line is a new one', () => {
|
||||||
|
assert.equal(md('one\ntwo'), '<p>one<br>two</p>');
|
||||||
|
assert.equal(md('one\n\ntwo'), '<p>one</p><p>two</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an image is a picture when there is something to place it with', () => {
|
||||||
|
assert.equal(mdi(''),
|
||||||
|
'<p><img src="https://i.redd.it/x.jpg" alt="a cat"></p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an image degrades to a link when there is not', () => {
|
||||||
|
const out = md('');
|
||||||
|
assert.ok(out.includes('<a href="https://i.redd.it/x.jpg"'));
|
||||||
|
assert.ok(!out.includes('<img'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an image's `!` is not left behind as text", () => {
|
||||||
|
// The link rule would otherwise match from the `[` and strand the bang.
|
||||||
|
assert.ok(!mdi('').includes('!'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pasted image address becomes the picture, not a link to it', () => {
|
||||||
|
// Which is how most images in a Reddit comment arrive.
|
||||||
|
const out = mdi('look\n\nhttps://preview.redd.it/abc.jpeg?width=1274&s=deadbeef');
|
||||||
|
assert.ok(out.includes('<img src="https://preview.redd.it/abc.jpeg?width=1274&s=deadbeef"'));
|
||||||
|
assert.ok(!out.includes('<a href'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a link that is not an image is still a link', () => {
|
||||||
|
const out = mdi('see https://example.com/article');
|
||||||
|
assert.ok(out.includes('<a href="https://example.com/article"'));
|
||||||
|
assert.ok(!out.includes('<img'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sentence after a pasted image keeps its punctuation out of the address', () => {
|
||||||
|
const out = mdi('here https://i.redd.it/x.jpg.');
|
||||||
|
assert.ok(out.includes('src="https://i.redd.it/x.jpg"'), out);
|
||||||
|
assert.ok(out.endsWith('.</p>'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only http and https become pictures', () => {
|
||||||
|
const out = mdi(')');
|
||||||
|
assert.ok(!out.includes('<img'));
|
||||||
|
assert.ok(out.includes('![x]'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an image's alt text is escaped like anything else a stranger wrote", () => {
|
||||||
|
const out = mdi('');
|
||||||
|
assert.ok(!out.includes('onerror=alert(1)>'), out);
|
||||||
|
assert.ok(out.includes('"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('images inside a quote are still placed', () => {
|
||||||
|
assert.match(mdi('> '), /<blockquote><p><img/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an image address typed without a scheme is still the picture', () => {
|
||||||
|
// Which is how people type them: no https, straight from the address bar.
|
||||||
|
const out = mdi('preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&s=b27e');
|
||||||
|
assert.ok(out.includes('<img src="https://preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&s=b27e"'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a schemeless image address mid-sentence keeps the sentence', () => {
|
||||||
|
const out = mdi('look at i.redd.it/x.png nice one');
|
||||||
|
assert.ok(out.startsWith('<p>look at <img'), out);
|
||||||
|
assert.ok(out.endsWith(' nice one</p>'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prose full of dots and slashes is not mistaken for an address', () => {
|
||||||
|
// The reason this rule insists on a host, a path and an image extension.
|
||||||
|
for (const text of [
|
||||||
|
'the file is at src/render/post.ts',
|
||||||
|
'see node_modules/foo/bar.js',
|
||||||
|
'a path like ./images/cat.jpg',
|
||||||
|
'C:/Users/x/cat.png',
|
||||||
|
'email [email protected]/nope.jpg',
|
||||||
|
'version 1.2.3/4.png',
|
||||||
|
]) {
|
||||||
|
assert.ok(!mdi(text).includes('<img'), `treated as an image: ${text}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a schemeless address that is not an image is left alone', () => {
|
||||||
|
// Guessing a scheme is worth it for a picture and not for prose.
|
||||||
|
assert.equal(mdi('example.com/article'), '<p>example.com/article</p>');
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { commentsFrom, mediaFromLink, resolveInlineImages, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
|
||||||
|
import { reddit } from '../src/platforms/reddit.ts';
|
||||||
|
import { originalUrlFor } from '../src/platforms/index.ts';
|
||||||
|
import { fixture } from './helpers.ts';
|
||||||
|
|
||||||
|
const URL_ = 'https://www.reddit.com/r/aww/comments/abc123/a_post/';
|
||||||
|
|
||||||
|
test('a text post is its title, and carries no media', () => {
|
||||||
|
const post = toPost(fixture('reddit/self.json'), URL_);
|
||||||
|
assert.equal(post.platform, 'reddit');
|
||||||
|
assert.equal(post.author.handle, 'r/AskReddit');
|
||||||
|
assert.match(post.author.displayName ?? '', /^u\//);
|
||||||
|
assert.equal(post.segments[0]?.title, "What's a healthy food that pleases the taste buds too?");
|
||||||
|
assert.deepEqual(post.segments[0]?.media, []);
|
||||||
|
assert.ok(post.segments[0]?.postedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a gallery keeps every picture, in the order the post arranged them', () => {
|
||||||
|
const link = fixture<[{ data: { children: Array<{ data: Record<string, unknown> }> } }]>(
|
||||||
|
'reddit/gallery.json',
|
||||||
|
)[0].data.children[0]?.data as Parameters<typeof mediaFromLink>[0];
|
||||||
|
const media = mediaFromLink(link);
|
||||||
|
const ids = (link.gallery_data?.items ?? []).map((item) => item.media_id);
|
||||||
|
|
||||||
|
assert.equal(media.length, ids.length);
|
||||||
|
assert.ok(media.every((m) => m.kind === 'image'));
|
||||||
|
// The pictures live in `media_metadata`, keyed and unordered; the order is
|
||||||
|
// only in `gallery_data`, so joining the two is the whole job.
|
||||||
|
media.forEach((item, index) => assert.ok(item.url.includes(String(ids[index]))));
|
||||||
|
assert.ok(media.every((m) => m.width && m.height));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a silent video is served as the plain MP4, with a real image for a poster', () => {
|
||||||
|
const media = toPost(fixture('reddit/video.json'), URL_).segments[0]?.media ?? [];
|
||||||
|
assert.equal(media.length, 1);
|
||||||
|
const video = media[0];
|
||||||
|
assert.equal(video?.kind, 'video');
|
||||||
|
assert.match(video?.url ?? '', /\.mp4/);
|
||||||
|
assert.ok(video?.kind === 'video' && video.poster);
|
||||||
|
// Not the scrubber file, which is itself an MP4 and would render nothing.
|
||||||
|
assert.ok(video?.kind === 'video' && !video.poster?.url.endsWith('.mp4'));
|
||||||
|
assert.equal(video?.kind === 'video' ? video.hls : undefined, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a video with sound is the HLS playlist, because the MP4 has no audio track', () => {
|
||||||
|
const media = mediaFromLink({
|
||||||
|
secure_media: {
|
||||||
|
reddit_video: {
|
||||||
|
fallback_url: 'https://v.redd.it/abc/CMAF_720.mp4?source=fallback',
|
||||||
|
hls_url: 'https://v.redd.it/abc/HLSPlaylist.m3u8',
|
||||||
|
has_audio: true,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
duration: 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: { images: [{ source: { url: 'https://external-preview.redd.it/still.png', width: 1920, height: 1080 } }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(media, [
|
||||||
|
{
|
||||||
|
kind: 'video',
|
||||||
|
url: 'https://v.redd.it/abc/HLSPlaylist.m3u8',
|
||||||
|
hls: true,
|
||||||
|
direct: true,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
durationSec: 42,
|
||||||
|
// The still is the video's, not the MP4's. Dropping it here left every
|
||||||
|
// post with sound showing an empty box where a silent one showed a frame.
|
||||||
|
poster: { url: 'https://external-preview.redd.it/still.png' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a link post keeps the destination, since it is the whole content of the post', () => {
|
||||||
|
const post = toPost(fixture('reddit/link.json'), URL_);
|
||||||
|
assert.match(post.segments[0]?.text ?? '', /^https:\/\/www\.nytimes\.com\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a crosspost shows what it is crossposting', () => {
|
||||||
|
const media = mediaFromLink({
|
||||||
|
is_self: false,
|
||||||
|
crosspost_parent_list: [
|
||||||
|
{ url_overridden_by_dest: 'https://i.redd.it/inner.jpg', post_hint: 'image' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(media, [{ kind: 'image', url: 'https://i.redd.it/inner.jpg' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comments come back as a tree, with the counts of what is missing', () => {
|
||||||
|
const post = toPost(fixture('reddit/gallery.json'), URL_);
|
||||||
|
|
||||||
|
assert.equal(post.comments?.length, 3);
|
||||||
|
assert.ok((post.moreComments ?? 0) > 0, 'the "more comments" node should be counted, not dropped');
|
||||||
|
assert.equal(post.commentCount, 443);
|
||||||
|
|
||||||
|
const first = post.comments?.[0];
|
||||||
|
assert.equal(first?.author, 'u/Background_Round_853');
|
||||||
|
assert.equal(first?.isAuthor, true, 'the poster replying under their own post');
|
||||||
|
assert.equal(first?.score, 6583);
|
||||||
|
assert.ok((first?.replies.length ?? 0) > 0);
|
||||||
|
assert.ok(first?.replies.every((reply) => Array.isArray(reply.replies)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a score the platform is still hiding is left off rather than guessed at', () => {
|
||||||
|
const { comments } = commentsFrom({
|
||||||
|
data: {
|
||||||
|
children: [
|
||||||
|
{ kind: 't1', data: { author: 'a', body: 'new', score: 1, score_hidden: true, replies: '' } },
|
||||||
|
{ kind: 't1', data: { author: 'b', body: 'older', score: 42, score_hidden: false, replies: '' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(comments[0]?.score, undefined);
|
||||||
|
assert.equal(comments[1]?.score, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deleted commenter keeps the platform\'s own word for it', () => {
|
||||||
|
const { comments } = commentsFrom({
|
||||||
|
data: { children: [{ kind: 't1', data: { author: '[deleted]', body: '[removed]', replies: '' } }] },
|
||||||
|
});
|
||||||
|
assert.equal(comments[0]?.author, '[deleted]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty reply listing is the string "", not an object', () => {
|
||||||
|
// Reddit says "no replies" with an empty string, which is the shape most
|
||||||
|
// likely to be read as a listing and crash the walk.
|
||||||
|
const { comments } = commentsFrom({
|
||||||
|
data: { children: [{ kind: 't1', data: { author: 'a', body: 'x', replies: '' } }] },
|
||||||
|
});
|
||||||
|
assert.deepEqual(comments[0]?.replies, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a redd.it share code is rebuilt, since the rewrite drops the host', () => {
|
||||||
|
assert.equal(originalUrlFor(reddit, 'abc123', ''), 'https://redd.it/abc123');
|
||||||
|
assert.equal(
|
||||||
|
originalUrlFor(reddit, 'r/aww/comments/abc123/a_post/', '?share_id=xyz&utm_source=share'),
|
||||||
|
'https://www.reddit.com/r/aww/comments/abc123/a_post/',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reddit links route to the adapter, and its media hosts do not', () => {
|
||||||
|
assert.ok(reddit.matchesHost('www.reddit.com'));
|
||||||
|
assert.ok(reddit.matchesHost('old.reddit.com'));
|
||||||
|
assert.ok(reddit.matchesHost('redd.it'));
|
||||||
|
assert.ok(!reddit.matchesHost('bsky.app'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the page fallback rebuilds nesting from the depth on each comment', () => {
|
||||||
|
// Reddit renders the tree flat, so depth is the only thing saying what
|
||||||
|
// replies to what.
|
||||||
|
const tree = treeFromDepths([
|
||||||
|
{ depth: 0, author: 'a', score: 5, created: '', text: 'first' },
|
||||||
|
{ depth: 1, author: 'b', score: 4, created: '', text: 'under first' },
|
||||||
|
{ depth: 2, author: 'c', score: 3, created: '', text: 'under b' },
|
||||||
|
// Back up two levels: this belongs to `first`, not to `c`.
|
||||||
|
{ depth: 1, author: 'd', score: 2, created: '', text: 'also under first' },
|
||||||
|
{ depth: 0, author: 'e', score: 1, created: '', text: 'second' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(tree.length, 2);
|
||||||
|
assert.equal(tree[0]?.replies.length, 2);
|
||||||
|
assert.equal(tree[0]?.replies[0]?.replies[0]?.text, 'under b');
|
||||||
|
assert.equal(tree[0]?.replies[1]?.text, 'also under first');
|
||||||
|
assert.deepEqual(tree[1]?.replies, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a comment the page gave no text for is dropped rather than shown empty', () => {
|
||||||
|
assert.deepEqual(treeFromDepths([{ depth: 0, author: 'a', score: 1, created: '', text: '' }]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real shapes, captured from comments carrying each kind.
|
||||||
|
const GIPHY = {
|
||||||
|
'giphy|Ve7wX45gaOFmw8eeEM': {
|
||||||
|
status: 'valid',
|
||||||
|
e: 'AnimatedImage',
|
||||||
|
m: 'image/gif',
|
||||||
|
s: {
|
||||||
|
y: 200,
|
||||||
|
x: 304,
|
||||||
|
gif: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9',
|
||||||
|
mp4: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&format=mp4&s=a389',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const UPLOAD = {
|
||||||
|
jo8gf0ca92zd1: {
|
||||||
|
status: 'valid',
|
||||||
|
e: 'Image',
|
||||||
|
m: 'image/jpeg',
|
||||||
|
s: { y: 1270, x: 1274, u: 'https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('a giphy comment points at the gif rather than at a token', () => {
|
||||||
|
// `` is not an address, and renders as nothing at all until
|
||||||
|
// it is looked up in the comment's own media_metadata.
|
||||||
|
assert.equal(
|
||||||
|
resolveInlineImages('', GIPHY),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the animated form takes the gif, which plays on its own', () => {
|
||||||
|
const out = resolveInlineImages('', GIPHY);
|
||||||
|
assert.ok(out.includes('.gif?'), out);
|
||||||
|
assert.ok(!out.includes('format=mp4'), 'an mp4 would need a player to move');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an uploaded image resolves through the same lookup', () => {
|
||||||
|
// Giphy, emotes and uploads are all a token that is a key in the same map,
|
||||||
|
// so none of them needs naming.
|
||||||
|
assert.equal(
|
||||||
|
resolveInlineImages('', UPLOAD),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a target that is already an address is left alone', () => {
|
||||||
|
const already = '';
|
||||||
|
assert.equal(resolveInlineImages(already, UPLOAD), already);
|
||||||
|
assert.equal(resolveInlineImages(already, undefined), already);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a token with no entry, or a broken one, is not invented', () => {
|
||||||
|
// An upload id and an emote id mean nothing off Reddit, so with no entry to
|
||||||
|
// look them up in there is nothing to point them at.
|
||||||
|
assert.equal(resolveInlineImages('', GIPHY), '');
|
||||||
|
assert.equal(
|
||||||
|
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', () => {
|
||||||
|
const { comments } = commentsFrom({
|
||||||
|
data: {
|
||||||
|
children: [{
|
||||||
|
kind: 't1',
|
||||||
|
data: {
|
||||||
|
author: 'a',
|
||||||
|
body: 'ha ',
|
||||||
|
media_metadata: GIPHY,
|
||||||
|
replies: '',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.match(comments[0]?.text ?? '', /external-preview\.redd\.it/);
|
||||||
|
});
|
||||||
+187
-9
@@ -4,7 +4,7 @@ import { escapeHtml, html, raw } from '../src/render/html.ts';
|
|||||||
import { linkify } from '../src/render/text.ts';
|
import { linkify } from '../src/render/text.ts';
|
||||||
import { renderPost } from '../src/render/post.ts';
|
import { renderPost } from '../src/render/post.ts';
|
||||||
import { renderError } from '../src/render/error.ts';
|
import { renderError } from '../src/render/error.ts';
|
||||||
import type { Post } from '../src/types.ts';
|
import { oneSegment, type Media, type Post } from '../src/types.ts';
|
||||||
|
|
||||||
test('interpolations are escaped, Raw values are not', () => {
|
test('interpolations are escaped, Raw values are not', () => {
|
||||||
assert.equal(html`<p>${'<script>alert(1)</script>'}</p>`.value,
|
assert.equal(html`<p>${'<script>alert(1)</script>'}</p>`.value,
|
||||||
@@ -42,13 +42,19 @@ function post(overrides: Partial<Post> = {}): Post {
|
|||||||
platformLabel: 'Instagram',
|
platformLabel: 'Instagram',
|
||||||
originalUrl: 'https://www.instagram.com/p/ABC/',
|
originalUrl: 'https://www.instagram.com/p/ABC/',
|
||||||
author: { handle: '@nasa' },
|
author: { handle: '@nasa' },
|
||||||
text: 'caption',
|
|
||||||
textPosition: 'below',
|
textPosition: 'below',
|
||||||
media: [{ kind: 'image', url: 'https://cdn/1.jpg' }, { kind: 'image', url: 'https://cdn/2.jpg' }],
|
segments: oneSegment({
|
||||||
|
text: 'caption',
|
||||||
|
media: [{ kind: 'image', url: 'https://cdn/1.jpg' }, { kind: 'image', url: 'https://cdn/2.jpg' }],
|
||||||
|
}),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withMedia(media: Media[], overrides: Partial<Post> = {}): Post {
|
||||||
|
return post({ segments: oneSegment({ text: 'caption', media }), ...overrides });
|
||||||
|
}
|
||||||
|
|
||||||
test('media is proxied, never linked straight at the CDN', () => {
|
test('media is proxied, never linked straight at the CDN', () => {
|
||||||
const page = renderPost(post());
|
const page = renderPost(post());
|
||||||
assert.ok(!page.includes('https://cdn/1.jpg'), 'upstream URLs must not reach the page');
|
assert.ok(!page.includes('https://cdn/1.jpg'), 'upstream URLs must not reach the page');
|
||||||
@@ -56,9 +62,9 @@ test('media is proxied, never linked straight at the CDN', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('an HLS video is linked directly, because a proxy cannot rewrite a playlist', () => {
|
test('an HLS video is linked directly, because a proxy cannot rewrite a playlist', () => {
|
||||||
const page = renderPost(post({
|
const page = renderPost(withMedia([
|
||||||
media: [{ kind: 'video', url: 'https://video.bsky.app/x/playlist.m3u8', hls: true, direct: true }],
|
{ kind: 'video', url: 'https://video.bsky.app/x/playlist.m3u8', hls: true, direct: true },
|
||||||
}));
|
]));
|
||||||
assert.ok(page.includes('https://video.bsky.app/x/playlist.m3u8'));
|
assert.ok(page.includes('https://video.bsky.app/x/playlist.m3u8'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,18 +78,39 @@ test('text sits below the media for Instagram and above it for X', () => {
|
|||||||
|
|
||||||
test('the layout toggle only appears when there is more than one item', () => {
|
test('the layout toggle only appears when there is more than one item', () => {
|
||||||
assert.ok(renderPost(post()).includes('data-view="grid"'));
|
assert.ok(renderPost(post()).includes('data-view="grid"'));
|
||||||
assert.ok(!renderPost(post({ media: [{ kind: 'image', url: 'https://cdn/1.jpg' }] }))
|
assert.ok(!renderPost(withMedia([{ kind: 'image', url: 'https://cdn/1.jpg' }]))
|
||||||
.includes('data-view="grid"'));
|
.includes('data-view="grid"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a video gets native controls and a source, not an iframe', () => {
|
test('a video gets native controls and a source, not an iframe', () => {
|
||||||
const page = renderPost(post({ media: [{ kind: 'video', url: 'https://cdn/v.mp4' }] }));
|
const page = renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }]));
|
||||||
assert.ok(page.includes('<video'));
|
assert.ok(page.includes('<video'));
|
||||||
assert.ok(page.includes('controls'));
|
assert.ok(page.includes('controls'));
|
||||||
assert.ok(page.includes('playsinline'));
|
assert.ok(page.includes('playsinline'));
|
||||||
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/"'));
|
||||||
@@ -99,6 +126,157 @@ 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 {
|
||||||
|
return post({
|
||||||
|
platform: 'reddit',
|
||||||
|
platformLabel: 'Reddit',
|
||||||
|
originalUrl: 'https://www.reddit.com/r/aww/comments/abc/a/',
|
||||||
|
author: { handle: 'r/aww' },
|
||||||
|
textPosition: 'above',
|
||||||
|
segments: oneSegment({ title: 'A headline', media: [] }),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a post with no comments renders no comment section at all', () => {
|
||||||
|
assert.ok(!renderPost(redditPost()).includes('class="thread"'));
|
||||||
|
assert.ok(!renderPost(post()).includes('class="thread"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comments nest, so collapsing one takes its replies with it', () => {
|
||||||
|
const page = renderPost(redditPost({
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
author: 'u/a',
|
||||||
|
text: 'top',
|
||||||
|
replies: [{ author: 'u/b', text: 'nested', replies: [] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The reply is inside the parent's <details>, not a sibling of it.
|
||||||
|
const parent = page.slice(page.indexOf('<details class="c"'));
|
||||||
|
const closing = parent.indexOf('</details>');
|
||||||
|
assert.ok(parent.slice(0, closing).includes('nested'), 'a reply must live inside its parent');
|
||||||
|
assert.ok(page.includes('<details class="c" open'), 'threads start expanded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a comment says how much a fold would hide', () => {
|
||||||
|
const page = renderPost(redditPost({
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
author: 'u/a',
|
||||||
|
text: 'top',
|
||||||
|
replies: [
|
||||||
|
{ author: 'u/b', text: 'one', replies: [{ author: 'u/c', text: 'two', replies: [] }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
assert.ok(page.includes('+2 replies'), 'the whole subtree is counted, not just direct replies');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('comment text is escaped, and its markdown is rendered', () => {
|
||||||
|
const page = renderPost(redditPost({
|
||||||
|
comments: [{ author: 'u/a', text: '**bold** <img src=x onerror=alert(1)>', replies: [] }],
|
||||||
|
}));
|
||||||
|
assert.ok(!page.includes('<img src=x'), 'markup in a comment must not survive');
|
||||||
|
assert.ok(page.includes('<strong>bold</strong>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('what the first page did not carry is counted rather than pretended away', () => {
|
||||||
|
const page = renderPost(redditPost({
|
||||||
|
comments: [{ author: 'u/a', text: 'x', replies: [], moreReplies: 3 }],
|
||||||
|
moreComments: 40,
|
||||||
|
commentCount: 443,
|
||||||
|
}));
|
||||||
|
assert.ok(page.includes('3 more replies, on Reddit'));
|
||||||
|
assert.ok(page.includes('40 more, behind'));
|
||||||
|
assert.ok(page.includes('1 of 443'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function quoting(quoted: Post['segments'][number]['quoted']): Post {
|
||||||
|
return post({
|
||||||
|
platform: 'x',
|
||||||
|
platformLabel: 'X',
|
||||||
|
textPosition: 'above',
|
||||||
|
segments: oneSegment({ text: 'look at this', media: [], quoted }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a quoted post is drawn with its own author, not folded under the quoter', () => {
|
||||||
|
const page = quoting({
|
||||||
|
author: { handle: '@other', displayName: 'Someone Else' },
|
||||||
|
text: 'the original words',
|
||||||
|
media: [],
|
||||||
|
url: 'https://x.com/other/status/9',
|
||||||
|
});
|
||||||
|
const html_ = renderPost(page);
|
||||||
|
|
||||||
|
assert.ok(html_.includes('class="quote"'));
|
||||||
|
assert.ok(html_.includes('@other'), 'the quoted handle must be on the page');
|
||||||
|
assert.ok(html_.includes('Someone Else'));
|
||||||
|
assert.ok(html_.includes('the original words'));
|
||||||
|
assert.ok(html_.includes('href="https://x.com/other/status/9"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a quoted post with no quote renders no block', () => {
|
||||||
|
assert.ok(!renderPost(quoting(undefined)).includes('class="quote"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the quoted post's media is proxied like any other", () => {
|
||||||
|
const html_ = renderPost(quoting({
|
||||||
|
author: { handle: '@other' },
|
||||||
|
media: [{ kind: 'image', url: 'https://pbs.twimg.com/q.jpg' }],
|
||||||
|
}));
|
||||||
|
assert.ok(!html_.includes('https://pbs.twimg.com/q.jpg'), 'upstream URLs must not reach the page');
|
||||||
|
assert.ok(html_.includes('src="/m/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a quoted post is separated from the words that quote it', () => {
|
||||||
|
const html_ = renderPost(quoting({
|
||||||
|
author: { handle: '@other' },
|
||||||
|
text: 'quoted words',
|
||||||
|
media: [],
|
||||||
|
}));
|
||||||
|
// The quoter's text closes before the block opens, so the two can never
|
||||||
|
// read as one paragraph by the same person.
|
||||||
|
assert.ok(html_.indexOf('look at this') < html_.indexOf('class="quote"'));
|
||||||
|
assert.ok(html_.indexOf('class="quote"') < html_.indexOf('quoted words'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markup in a quoted post is escaped like any other stranger\'s text', () => {
|
||||||
|
const html_ = renderPost(quoting({
|
||||||
|
author: { handle: '<img src=x onerror=alert(1)>' },
|
||||||
|
text: '<script>alert(1)</script>',
|
||||||
|
media: [],
|
||||||
|
}));
|
||||||
|
assert.ok(!html_.includes('<script>alert(1)</script>'));
|
||||||
|
assert.ok(!html_.includes('<img src=x'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a picture in a comment is proxied, and its alt text cannot break out', () => {
|
||||||
|
const page = renderPost(redditPost({
|
||||||
|
comments: [{
|
||||||
|
author: 'u/a',
|
||||||
|
text: '\n\nhttps://i.redd.it/y.png',
|
||||||
|
replies: [],
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.ok(!page.includes('https://preview.redd.it/x.jpeg'), 'upstream URLs must not reach the page');
|
||||||
|
assert.ok(!page.includes('https://i.redd.it/y.png'), 'a pasted address is proxied too');
|
||||||
|
assert.equal((page.match(/<img class="c__img" src="\/m\//g) ?? []).length, 2);
|
||||||
|
// The payload survives as text inside the attribute, which is the point:
|
||||||
|
// its quotes are neutered, so it cannot close `alt="` and become markup.
|
||||||
|
assert.ok(page.includes('alt="" onerror=alert(1) x=""'), 'alt text must be escaped');
|
||||||
|
assert.ok(!/alt="" onerror/.test(page), 'the attribute must not be closable');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { selfThread } from '../src/platforms/threads.ts';
|
||||||
|
import { fixture } from './helpers.ts';
|
||||||
|
|
||||||
|
type Post = Parameters<typeof selfThread>[0][number];
|
||||||
|
|
||||||
|
// A real capture of a two-post NASA thread. The page ships eighteen posts:
|
||||||
|
// the two that belong together, other people's replies to them, and a pile of
|
||||||
|
// unrelated recommendations. Only the first two may survive.
|
||||||
|
const page = () => fixture<Post[]>('threads/thread-chain.json');
|
||||||
|
|
||||||
|
test('the author\'s own follow-up is kept, in order', () => {
|
||||||
|
const chain = selfThread(page(), 'DccGgKuEW-E');
|
||||||
|
assert.equal(chain.length, 2);
|
||||||
|
assert.deepEqual(chain.map((p) => p.code), ['DccGgKuEW-E', 'DccGhYSkXYY']);
|
||||||
|
assert.match(chain[0]?.caption?.text ?? '', /Home improvement/);
|
||||||
|
assert.match(chain[1]?.caption?.text ?? '', /where you can watch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every other author on the page is dropped', () => {
|
||||||
|
const chain = selfThread(page(), 'DccGgKuEW-E');
|
||||||
|
assert.ok(chain.every((p) => p.user?.username === 'nasa'),
|
||||||
|
'a post by someone else survived the filter');
|
||||||
|
assert.ok(page().length > chain.length + 10, 'the fixture should be mostly noise');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reply from someone else to the same post is not a continuation', () => {
|
||||||
|
// This one is the trap: it carries reply_to_author = nasa, exactly like a
|
||||||
|
// real follow-up does. Only the author of the *post* separates them.
|
||||||
|
const esa = page().find((p) => p.user?.username === 'europeanspaceagency');
|
||||||
|
assert.ok(esa, 'fixture should contain a reply from another account');
|
||||||
|
assert.equal(esa?.text_post_app_info?.reply_to_author?.username, 'nasa');
|
||||||
|
assert.ok(!selfThread(page(), 'DccGgKuEW-E').includes(esa as Post));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('linking the second post still returns the whole chain', () => {
|
||||||
|
const chain = selfThread(page(), 'DccGhYSkXYY');
|
||||||
|
assert.deepEqual(chain.map((p) => p.code), ['DccGgKuEW-E', 'DccGhYSkXYY']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown code yields nothing rather than guessing', () => {
|
||||||
|
assert.deepEqual(selfThread(page(), 'NoSuchCode'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lone post is a chain of one', () => {
|
||||||
|
const chain = selfThread(
|
||||||
|
[
|
||||||
|
{ code: 'A', taken_at: 1, user: { username: 'me' }, caption: { text: 'only' } },
|
||||||
|
{ code: 'B', taken_at: 2, user: { username: 'you' }, caption: { text: 'reply' },
|
||||||
|
text_post_app_info: { reply_to_author: { username: 'me' } } },
|
||||||
|
],
|
||||||
|
'A',
|
||||||
|
);
|
||||||
|
assert.deepEqual(chain.map((p) => p.code), ['A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the chain is ordered by when each was posted, not by payload order', () => {
|
||||||
|
const chain = selfThread(
|
||||||
|
[
|
||||||
|
{ code: 'C', taken_at: 30, user: { username: 'me' },
|
||||||
|
text_post_app_info: { reply_to_author: { username: 'me' } } },
|
||||||
|
{ code: 'A', taken_at: 10, user: { username: 'me' } },
|
||||||
|
{ code: 'B', taken_at: 20, user: { username: 'me' },
|
||||||
|
text_post_app_info: { reply_to_author: { username: 'me' } } },
|
||||||
|
],
|
||||||
|
'A',
|
||||||
|
);
|
||||||
|
assert.deepEqual(chain.map((p) => p.code), ['A', 'B', 'C']);
|
||||||
|
});
|
||||||
+116
-17
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { mediaFromDetails, toPost } from '../src/platforms/x.ts';
|
import { mediaFromDetails, quotedFrom, toPost } from '../src/platforms/x.ts';
|
||||||
import { fixture } from './helpers.ts';
|
import { fixture } from './helpers.ts';
|
||||||
|
|
||||||
const URL_ = 'https://x.com/example/status/1';
|
const URL_ = 'https://x.com/example/status/1';
|
||||||
@@ -9,16 +9,16 @@ test('a text-only post carries no media', () => {
|
|||||||
const post = toPost(fixture('x/text-only.json'), URL_);
|
const post = toPost(fixture('x/text-only.json'), URL_);
|
||||||
assert.equal(post.platform, 'x');
|
assert.equal(post.platform, 'x');
|
||||||
assert.equal(post.textPosition, 'above');
|
assert.equal(post.textPosition, 'above');
|
||||||
assert.deepEqual(post.media, []);
|
assert.deepEqual(post.segments[0]?.media, []);
|
||||||
assert.equal(post.author.handle, '@jack');
|
assert.equal(post.author.handle, '@jack');
|
||||||
assert.ok(post.postedAt);
|
assert.ok(post.segments[0]?.postedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a photo post keeps every photo and asks for the original size', () => {
|
test('a photo post keeps every photo and asks for the original size', () => {
|
||||||
const post = toPost(fixture('x/photo.json'), URL_);
|
const media = toPost(fixture('x/photo.json'), URL_).segments[0]?.media ?? [];
|
||||||
assert.ok(post.media.length > 1, 'expected more than one photo');
|
assert.ok(media.length > 1, 'expected more than one photo');
|
||||||
assert.ok(post.media.every((m) => m.kind === 'image'));
|
assert.ok(media.every((m) => m.kind === 'image'));
|
||||||
assert.ok(post.media.every((m) => m.url.endsWith('?name=orig')));
|
assert.ok(media.every((m) => m.url.endsWith('?name=orig')));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a video picks the highest-bitrate mp4 and ignores the streaming variants', () => {
|
test('a video picks the highest-bitrate mp4 and ignores the streaming variants', () => {
|
||||||
@@ -50,20 +50,119 @@ test('a video picks the highest-bitrate mp4 and ignores the streaming variants',
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a quote post shows the media of the post it quotes', () => {
|
test('a quote post carries the post it quotes, whose it is included', () => {
|
||||||
// The quote itself carries none; without this the page is text and nothing
|
// Someone continuing their own thought from an earlier post. Without the
|
||||||
// else, which is exactly the thing the reader wanted to see.
|
// quoted half on the page the remaining half says nothing.
|
||||||
|
const post = toPost(fixture('x/quote-text.json'), URL_);
|
||||||
|
const quoted = post.segments[0]?.quoted;
|
||||||
|
|
||||||
|
assert.ok(quoted, 'the quoted post must survive');
|
||||||
|
assert.equal(quoted?.author.handle, '@MikeMcMahonCHN');
|
||||||
|
assert.ok(quoted?.author.displayName);
|
||||||
|
assert.match(quoted?.text ?? '', /Matthew Mayich/);
|
||||||
|
assert.ok(quoted?.postedAt);
|
||||||
|
// No permalink in the payload, so it is rebuilt from the handle and the id.
|
||||||
|
assert.equal(quoted?.url, 'https://x.com/MikeMcMahonCHN/status/2092384930034548928');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the quoted post keeps its own pictures instead of lending them to the quoter', () => {
|
||||||
|
const post = toPost(fixture('x/quote-photo.json'), URL_);
|
||||||
|
|
||||||
|
assert.deepEqual(post.segments[0]?.media, [], 'the quoter attached nothing');
|
||||||
|
assert.equal(post.segments[0]?.quoted?.media.length, 1);
|
||||||
|
assert.equal(post.segments[0]?.quoted?.media[0]?.kind, 'image');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media on both sides stays on the side it came from', () => {
|
||||||
|
// This is the case the old either-or could not represent at all: it showed
|
||||||
|
// the quoter's picture and silently dropped the one being talked about.
|
||||||
|
const segment = toPost(fixture('x/quote-both.json'), URL_).segments[0];
|
||||||
|
|
||||||
|
assert.equal(segment?.media.length, 1);
|
||||||
|
assert.equal(segment?.quoted?.media.length, 1);
|
||||||
|
assert.notEqual(segment?.media[0]?.url, segment?.quoted?.media[0]?.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a post quoting nothing has no quoted post', () => {
|
||||||
|
assert.equal(toPost(fixture('x/photo.json'), URL_).segments[0]?.quoted, undefined);
|
||||||
|
assert.equal(quotedFrom(undefined), undefined);
|
||||||
|
// A quoted post X will not describe is nothing to show.
|
||||||
|
assert.equal(quotedFrom({ text: 'orphaned' }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the shortlink X staples to a quote post is not shown', () => {
|
||||||
|
// The quoted post is right there on the page; a t.co pointing at it is
|
||||||
|
// noise, which is why X hides it too. `display_text_range` says where it
|
||||||
|
// starts, and keeps a link the author put there deliberately.
|
||||||
|
const both = toPost(fixture('x/quote-both.json'), URL_).segments[0];
|
||||||
|
// Two links arrive: one the author wrote, one X appended pointing at the
|
||||||
|
// quoted post. Exactly the second goes.
|
||||||
|
assert.equal((both?.text?.match(/https?:\/\//g) ?? []).length, 1, "the author's own link stays");
|
||||||
|
assert.match(both?.text ?? '', /on3\.com/);
|
||||||
|
assert.ok(!both?.text?.includes('QdJhOVu4En'));
|
||||||
|
|
||||||
|
const photo = toPost(fixture('x/quote-photo.json'), URL_).segments[0];
|
||||||
|
assert.ok(!photo?.quoted?.text?.includes('t.co'), 'and the same on the quoted post');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a t.co stands aside for the address it stands in for', () => {
|
||||||
|
// Left alone the page says `t.co/rEGx9JN5pJ`, which tells the reader
|
||||||
|
// nothing and sends them through X's click tracker to find out.
|
||||||
|
const segment = toPost(fixture('x/quote-both.json'), URL_).segments[0];
|
||||||
|
assert.ok(!segment?.text?.includes('t.co/'), `still shortened: ${segment?.text}`);
|
||||||
|
assert.match(segment?.text ?? '', /on3\.com/);
|
||||||
|
assert.match(segment?.quoted?.text ?? '', /on3\.com/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a shortlink the payload does not explain is left as it is', () => {
|
||||||
const post = toPost(
|
const post = toPost(
|
||||||
{
|
{
|
||||||
text: 'look at this',
|
text: 'see https://t.co/unknown1 and https://t.co/known123',
|
||||||
user: { screen_name: 'someone' },
|
user: { screen_name: 'someone' },
|
||||||
mediaDetails: [],
|
entities: { urls: [{ url: 'https://t.co/known123', expanded_url: 'https://example.com/real' }] },
|
||||||
quoted_tweet: {
|
|
||||||
mediaDetails: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/q.jpg' }],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
URL_,
|
URL_,
|
||||||
);
|
);
|
||||||
assert.equal(post.media.length, 1);
|
assert.equal(post.segments[0]?.text, 'see https://t.co/unknown1 and https://example.com/real');
|
||||||
assert.equal(post.media[0]?.url, 'https://pbs.twimg.com/q.jpg?name=orig');
|
});
|
||||||
|
|
||||||
|
test('an expansion that is not an http address is refused', () => {
|
||||||
|
const post = toPost(
|
||||||
|
{
|
||||||
|
text: 'https://t.co/abc',
|
||||||
|
user: { screen_name: 'someone' },
|
||||||
|
entities: { urls: [{ url: 'https://t.co/abc', expanded_url: 'javascript:alert(1)' }] },
|
||||||
|
},
|
||||||
|
URL_,
|
||||||
|
);
|
||||||
|
assert.equal(post.segments[0]?.text, 'https://t.co/abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the visible range is applied before the entities are decoded', () => {
|
||||||
|
// The indices are into the escaped text, where `&` is five characters.
|
||||||
|
// Decoding first shifts everything after it and truncates the tail.
|
||||||
|
const post = toPost(
|
||||||
|
{
|
||||||
|
text: 'Tom & Jerry https://t.co/xxx',
|
||||||
|
display_text_range: [0, 15],
|
||||||
|
user: { screen_name: 'someone' },
|
||||||
|
},
|
||||||
|
URL_,
|
||||||
|
);
|
||||||
|
assert.equal(post.segments[0]?.text, 'Tom & Jerry');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escaped characters X hands back are decoded, not shown as entities', () => {
|
||||||
|
// The syndication payload arrives pre-escaped; everything downstream
|
||||||
|
// escapes again on the way out, so leaving these puts `&` on screen.
|
||||||
|
const post = toPost(
|
||||||
|
{
|
||||||
|
text: 'Tom & Jerry <3 "quoted"',
|
||||||
|
user: { screen_name: 'someone' },
|
||||||
|
quoted_tweet: { text: 'me & you', user: { screen_name: 'other' }, id_str: '9' },
|
||||||
|
},
|
||||||
|
URL_,
|
||||||
|
);
|
||||||
|
assert.equal(post.segments[0]?.text, 'Tom & Jerry <3 "quoted"');
|
||||||
|
assert.equal(post.segments[0]?.quoted?.text, 'me & you');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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