Initial commit: read social posts back without the app
antisocial is the other half of a StopTheMadness redirect rule. Links to X, Threads, Instagram, TikTok and Bluesky get rewritten to /<prefix>/<original path>, and this resolves the post and shows the media and the words, with a badge saying where it came from and a button to copy the original URL. Every request drives a real headless Chromium, logged out, from a residential IP. One code path, and it survives markup changes better than parsing from the outside would. Extraction is layered, most structured first: the platform's own API response caught in flight, then an inline payload, then the rendered DOM, then Open Graph tags. Media is never linked straight at a CDN. Instagram and TikTok reject requests without a matching Referer and cookies, and proxying keeps the viewer's browser from talking to the platform at all. Range is forwarded so the native video scrubber can seek. HLS is the exception, since proxying it would mean rewriting playlists. TikTok sometimes answers with a slider puzzle. Rather than reporting that as a failure, the page is parked and the viewer is handed the puzzle: screenshots stream out, pointer events are replayed back. Solving it leaves the cookie in the shared browser context, so the retry is an ordinary request. A failed resolve is never a blank error page. The card carries the platform, the original URL and the copy button, so a broken adapter still leaves the link one tap away. Verified end to end against real shared links on all five platforms, in the container, including multi-image carousels, reels, TikTok short links and photo posts. 49 tests run the adapters against captured payloads with no network. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
test
|
||||||
|
bin
|
||||||
|
profile
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Typecheck, test and build on every pull request, and on main so a direct
|
||||||
|
# push cannot leave the branch red without anyone noticing.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Typecheck, test, build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: npm-
|
||||||
|
|
||||||
|
# Nothing here drives a browser: the adapter tests run against
|
||||||
|
# captured payloads. Skipping the download keeps the job to seconds.
|
||||||
|
- name: Install
|
||||||
|
run: npm ci
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Publish
|
||||||
|
|
||||||
|
# Builds the application image and pushes it to the Gitea container registry
|
||||||
|
# as git.unsupervised.ca/unsupervised/antisocial.
|
||||||
|
#
|
||||||
|
# Runs on main, and on demand. Pull requests build without pushing, so a
|
||||||
|
# broken Dockerfile is caught before it can move a published tag.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- '.gitea/workflows/publish.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.unsupervised.ca
|
||||||
|
IMAGE: unsupervised/antisocial
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Images are built natively, so each carries the architecture of the
|
||||||
|
# runner that built it. Every runner in the pool is arm64, which is
|
||||||
|
# also what Kallone is. A runner of a different architecture joining
|
||||||
|
# would overwrite these tags with its own arch, at which point this
|
||||||
|
# needs buildx and a multi-arch manifest.
|
||||||
|
- name: Check Docker is available
|
||||||
|
run: |
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "No usable Docker daemon in the job container." >&2
|
||||||
|
echo "act_runner needs container.docker_host set, or left empty to autodetect." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker version --format 'client {{.Client.Version}} / server {{.Server.Version}} / arch {{.Server.Arch}}'
|
||||||
|
|
||||||
|
- name: Work out the tags
|
||||||
|
id: tags
|
||||||
|
run: echo "sha=sha-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# REGISTRY_TOKEN is an organisation secret: a personal access token
|
||||||
|
# with the package scope, Read and Write. Gitea's Actions task token is
|
||||||
|
# rejected by the container registry (go-gitea/gitea#23642), so this
|
||||||
|
# cannot fall back to secrets.GITHUB_TOKEN. The login user must own the
|
||||||
|
# token; set the REGISTRY_USER variable if it is not github.actor.
|
||||||
|
- name: Log in to the container registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||||
|
echo "REGISTRY_TOKEN is not set. The Actions task token cannot push packages." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
| docker login "${REGISTRY}" -u "${{ vars.REGISTRY_USER || github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--pull \
|
||||||
|
--tag "${REGISTRY}/${IMAGE}:${{ steps.tags.outputs.sha }}" \
|
||||||
|
--tag "${REGISTRY}/${IMAGE}:latest" \
|
||||||
|
--file Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
docker push "${REGISTRY}/${IMAGE}:${{ steps.tags.outputs.sha }}"
|
||||||
|
docker push "${REGISTRY}/${IMAGE}:latest"
|
||||||
|
echo "Published ${REGISTRY}/${IMAGE}:${{ steps.tags.outputs.sha }}"
|
||||||
|
|
||||||
|
- name: Log out
|
||||||
|
if: always() && github.event_name != 'pull_request'
|
||||||
|
run: docker logout "${REGISTRY}" || true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
profile/
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# antisocial — working notes
|
||||||
|
|
||||||
|
A self-hosted page that shows a social post without the app. StopTheMadness rewrites
|
||||||
|
links to X, Threads, Instagram, TikTok and Bluesky into `/<prefix>/<original path>`;
|
||||||
|
this resolves the post by driving a real headless Chromium and renders the media, the
|
||||||
|
text, a platform badge and a copy-the-original button.
|
||||||
|
|
||||||
|
Single user, no auth, tailnet only, running on Kallone (lab3). `README.md` has the
|
||||||
|
rewrite rules and the user-facing description. This file is the working context.
|
||||||
|
|
||||||
|
## The main job: "this link didn't work"
|
||||||
|
|
||||||
|
That is the recurring task. Work it in this order and do not skip step 1.
|
||||||
|
|
||||||
|
**1. Reproduce against the real browser.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run resolve -- 'https://www.instagram.com/user/reel/ABC123/'
|
||||||
|
```
|
||||||
|
|
||||||
|
Prints the resolved `Post` as JSON, or the error. This is the ground truth.
|
||||||
|
|
||||||
|
**2. Decide what kind of failure it is.** These are genuinely different and get
|
||||||
|
different fixes:
|
||||||
|
|
||||||
|
| Symptom | What it usually is |
|
||||||
|
| --- | --- |
|
||||||
|
| Works now, failed before | Rate limiting. TikTok especially. Not a bug — verify by waiting and retrying before changing anything. |
|
||||||
|
| Verification puzzle | Expected. Should redirect to `/challenge/<id>`. If it reports an error instead, the detection raced the render. |
|
||||||
|
| Right media, wrong count | The structured payload was missed and it fell through to the DOM, which only shows the first carousel item. |
|
||||||
|
| Poster image where a video belongs | Same fall-through, plus the `<video>` had not hydrated when the DOM was read. |
|
||||||
|
| Nothing at all | The platform refused this specific post logged out, or a key moved. |
|
||||||
|
|
||||||
|
**3. Look at what the page actually served.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run probe -- 'https://www.instagram.com/reel/ABC123/embed/captioned/' 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass the URL *the adapter loads*, not the post URL — for Instagram that is the
|
||||||
|
`/embed/captioned/` form. It reports which payload keys are present, the DOM's
|
||||||
|
`<video>`/`<img>`, the OG tags, whether a challenge is up, and the API traffic with
|
||||||
|
bundle noise filtered out. Every adapter fix so far started here.
|
||||||
|
|
||||||
|
**4. Fix the adapter, then capture a fixture** for the case that broke, and assert on it
|
||||||
|
in `test/<platform>.test.ts`. Fixtures are real captured payloads — see the ones in
|
||||||
|
`test/fixtures/`. A fix without a fixture will silently regress.
|
||||||
|
|
||||||
|
**5. Re-run the whole suite** (`npm test`), and re-resolve the link end to end.
|
||||||
|
|
||||||
|
## Architecture, briefly
|
||||||
|
|
||||||
|
Request → `src/routes/post.ts` → `src/platforms/index.ts` maps prefix to adapter →
|
||||||
|
`src/resolve.ts` (cache + timeout + concurrency gate) → `withPage` from
|
||||||
|
`src/browser/pool.ts` → the adapter → a `Post` → `src/render/post.ts`.
|
||||||
|
|
||||||
|
Adding a platform is one file in `src/platforms/` plus one row in the table in
|
||||||
|
`index.ts`. Everything downstream already handles a `Post`.
|
||||||
|
|
||||||
|
Things worth knowing before editing:
|
||||||
|
|
||||||
|
- **One Chromium, one context, persistent.** Cookies and dismissed banners accumulate on
|
||||||
|
purpose — that is what makes traffic look like ordinary browsing. `MAX_CONCURRENT`
|
||||||
|
page loads, the rest queue.
|
||||||
|
- **Images, video and fonts are aborted** at the route layer. The URL is all we need; the
|
||||||
|
bytes get fetched later through our own proxy. Aborting does not remove `src`
|
||||||
|
attributes, so DOM extraction still works.
|
||||||
|
- **Extraction is layered**, most structured first: the platform's own API response
|
||||||
|
caught in flight → an inline payload → the rendered DOM → OG tags. Keep that order
|
||||||
|
when you touch an adapter; each layer is the fallback for the one above.
|
||||||
|
- **`src/platforms/scan.ts`** pulls a balanced JSON object out of a page by key,
|
||||||
|
including when it arrives escaped inside a JS string. Instagram needs this. If a
|
||||||
|
payload looks present but parses to nothing, suspect an extra encoding level.
|
||||||
|
- **Media is never linked straight at a CDN.** Assets are registered in
|
||||||
|
`src/media/registry.ts` and served from `/m/<id>` with the `Referer`/`Cookie` the CDN
|
||||||
|
demands. `Range` is forwarded — without it the native video scrubber cannot seek.
|
||||||
|
The exception is HLS (`direct: true`), because proxying would mean rewriting playlists.
|
||||||
|
- **Never a bare error page.** A failed resolve renders a card carrying the platform, the
|
||||||
|
original URL and the copy button. A broken adapter must still leave the link one tap
|
||||||
|
away.
|
||||||
|
|
||||||
|
## Per-platform notes
|
||||||
|
|
||||||
|
- **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
|
||||||
|
deliberately matches only V1.
|
||||||
|
- **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.
|
||||||
|
- **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
|
||||||
|
DOM is the floor and only ever shows the first carousel item. Reels hydrate their
|
||||||
|
`<video>` roughly 0.6–1.3s after `domcontentloaded`, so the DOM read waits when a video
|
||||||
|
is expected.
|
||||||
|
- **TikTok** — the post lives under whichever `__UNIVERSAL_DATA_FOR_REHYDRATION__` scope
|
||||||
|
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
|
||||||
|
segment and are rebuilt in `buildOriginalUrl`.
|
||||||
|
- **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its
|
||||||
|
payloads are full of empty stub nodes, so the finder only accepts a node with actual
|
||||||
|
candidates in it.
|
||||||
|
|
||||||
|
## Verification puzzles
|
||||||
|
|
||||||
|
TikTok challenges sometimes. The page is parked (`src/challenge/registry.ts`), kept open
|
||||||
|
but outside the pool holding no concurrency permit, and the viewer is redirected to
|
||||||
|
`/challenge/<id>`, which streams screenshots of the puzzle and replays their pointer
|
||||||
|
events onto it. Solving it deposits the cookie in the shared context, so the retry is an
|
||||||
|
ordinary request.
|
||||||
|
|
||||||
|
Login walls are not passed through — those need an account and cannot be solved this way.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- TypeScript 7, ESM, `.ts` import specifiers (tsc rewrites them to `.js` on emit).
|
||||||
|
Node's type stripping runs the tree directly for dev, tests and the CLIs.
|
||||||
|
- **No ESLint** — `typescript-eslint` does not support TS 7 yet. `npm run typecheck` is
|
||||||
|
the lint step.
|
||||||
|
- Tests are `node:test` against captured fixtures. No network in the test suite.
|
||||||
|
- Comments explain *why*, especially where the code looks odd because a platform is odd.
|
||||||
|
Match that; do not add narration of what the next line does.
|
||||||
|
- Post text comes from strangers: everything goes through the `html` tagged template or
|
||||||
|
`linkify`, both of which escape. Do not build markup by concatenation.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run resolve -- '<url>' # resolve one post, print JSON <- start here
|
||||||
|
npm run probe -- '<url>' # what the page actually served <- then here
|
||||||
|
npm test # fixtures, no network
|
||||||
|
npm run typecheck
|
||||||
|
npm run dev # http://localhost:8080
|
||||||
|
container build --tag antisocial:dev . # Apple container, not Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
CI (`.gitea/workflows/publish.yml`) builds on main and pushes
|
||||||
|
`git.unsupervised.ca/unsupervised/antisocial:latest` and `:sha-<short>`. Manifests live
|
||||||
|
in the **`infra` repo** at `k8s/kallone/antisocial.unsupervised.studio/`; Flux syncs
|
||||||
|
`k8s/kallone` every 10 minutes. The DNS CNAME is in `infra/cloudflare/unsupervised.studio.tf`.
|
||||||
|
|
||||||
|
Chromium needs the memory-backed `/dev/shm` volume in the deployment — it crashes against
|
||||||
|
the 64Mi default. Measured 623Mi resident.
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# antisocial — reads social posts back to you without the app.
|
||||||
|
#
|
||||||
|
# Both stages sit on the Playwright image so the Node that compiles the code
|
||||||
|
# is the Node that runs it, and so the Chromium build matches the Playwright
|
||||||
|
# package exactly. The image is large because a browser is large; the
|
||||||
|
# alternative, apt Chromium on a slim Node base, saves a few hundred MB and
|
||||||
|
# is where arm64 browser builds usually go wrong.
|
||||||
|
#
|
||||||
|
# Pinned in lockstep with the `playwright` dependency in package.json.
|
||||||
|
ARG PLAYWRIGHT_VERSION=1.62.1
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Browsers are already in the base image; downloading them again during
|
||||||
|
# `npm ci` would double the build for nothing.
|
||||||
|
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY tsconfig.json tsconfig.build.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build && npm prune --omit=dev
|
||||||
|
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
PORT=8080 \
|
||||||
|
PROFILE_DIR=/data/profile
|
||||||
|
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY package.json ./
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
|
# The profile directory is normally a mounted volume; create it anyway so
|
||||||
|
# the image runs standalone.
|
||||||
|
RUN mkdir -p /data/profile && chown -R pwuser:pwuser /data /app
|
||||||
|
|
||||||
|
USER pwuser
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# antisocial
|
||||||
|
|
||||||
|
Reads social posts back to you without the app.
|
||||||
|
|
||||||
|
Links to X, Threads, Instagram, TikTok and Bluesky get shared constantly, and opening
|
||||||
|
one means an app interstitial, a login wall, a feed you didn't ask for, and a pile of
|
||||||
|
tracking. antisocial is the other half of a StopTheMadness rewrite rule: the link gets
|
||||||
|
redirected here, and you get the post — the media and the words — plus a badge saying
|
||||||
|
where it came from and a button to copy the original URL if you do want to go there.
|
||||||
|
|
||||||
|
Single user, no authentication, tailnet only. It resolves posts by driving a real
|
||||||
|
headless browser from a residential IP, logged out, exactly as if you'd opened the link
|
||||||
|
yourself.
|
||||||
|
|
||||||
|
Served at <https://antisocial.unsupervised.studio> from Kallone. Deployment manifests
|
||||||
|
live in the `infra` repo under `k8s/kallone/antisocial.unsupervised.studio/`.
|
||||||
|
|
||||||
|
## StopTheMadness rules
|
||||||
|
|
||||||
|
One redirect rule per platform. The host is swapped for antisocial plus a short platform
|
||||||
|
segment; the rest of the path is left alone, so the original is always recoverable and
|
||||||
|
readable in your history.
|
||||||
|
|
||||||
|
| Platform | Find | Replace |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| X | `^https://(?:www\.\|mobile\.)?(?:x\|twitter)\.com/(.*)$` | `https://antisocial.unsupervised.studio/x/$1` |
|
||||||
|
| Threads | `^https://(?:www\.)?threads\.(?:net\|com)/(.*)$` | `https://antisocial.unsupervised.studio/threads/$1` |
|
||||||
|
| Instagram | `^https://(?:www\.)?instagram\.com/(.*)$` | `https://antisocial.unsupervised.studio/ig/$1` |
|
||||||
|
| TikTok | `^https://(?:www\.\|vm\.\|vt\.)?tiktok\.com/(.*)$` | `https://antisocial.unsupervised.studio/tiktok/$1` |
|
||||||
|
| Bluesky | `^https://bsky\.app/(.*)$` | `https://antisocial.unsupervised.studio/bsky/$1` |
|
||||||
|
|
||||||
|
So `https://x.com/user/status/123` becomes
|
||||||
|
`https://antisocial.unsupervised.studio/x/user/status/123`.
|
||||||
|
|
||||||
|
Tracking parameters (`igsh`, `utm_*`, `s`, `t`, and friends) are stripped on arrival, so
|
||||||
|
the URL the copy button gives back is the clean one. TikTok `vm.`/`vt.` share codes lose
|
||||||
|
their subdomain in the rewrite; a single opaque path segment is recognised as a share
|
||||||
|
code and rebuilt as `vm.tiktok.com/<code>/`.
|
||||||
|
|
||||||
|
`/` serves this table with the live hostnames, if you'd rather read it there.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Every request drives a real Chromium page load. One code path, and it survives markup
|
||||||
|
changes better than parsing HTML from the outside would.
|
||||||
|
|
||||||
|
Each adapter layers its extraction, most structured first:
|
||||||
|
|
||||||
|
1. **The platform's own API response**, caught as it goes past during the page load.
|
||||||
|
Reading the JSON a platform serves its own front end beats scraping what it renders.
|
||||||
|
2. **An inline payload** in the page — a JSON script tag, or an object buried in a
|
||||||
|
bootstrap call (`src/platforms/scan.ts` pulls a balanced object out by key, including
|
||||||
|
when it arrives escaped inside a JS string, which is what Instagram does).
|
||||||
|
3. **The rendered DOM** — whatever is actually on screen is real.
|
||||||
|
4. **Open Graph tags** — the floor, and enough to show something.
|
||||||
|
|
||||||
|
| Platform | Loads | Reads |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Bluesky | the public AT Protocol API | `getPostThread`; falls back to the post page |
|
||||||
|
| X | `platform.twitter.com` embed | the `cdn.syndication.twimg.com/tweet-result` response |
|
||||||
|
| Instagram | `/embed/captioned/` | `shortcode_media`, then the rendered `<video>`/`<img>` |
|
||||||
|
| TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
|
||||||
|
| Threads | the post page | the Relay payloads in `<script type="application/json">` |
|
||||||
|
|
||||||
|
Media never gets linked straight at a CDN. Instagram and TikTok reject requests without
|
||||||
|
a matching `Referer` (and sometimes cookies), and proxying keeps your browser from
|
||||||
|
talking to the platform at all. Every asset is registered under an opaque `/m/<id>` and
|
||||||
|
streamed through, with `Range` forwarded so the native video scrubber can seek.
|
||||||
|
|
||||||
|
The one exception is Bluesky video, which is an HLS playlist — proxying it would mean
|
||||||
|
rewriting the manifest and every segment, so it is linked directly. Safari plays HLS
|
||||||
|
natively; other browsers show a note.
|
||||||
|
|
||||||
|
Resolved posts are cached in memory for an hour, so a reload or a back button doesn't
|
||||||
|
drive the browser again.
|
||||||
|
|
||||||
|
## When a platform breaks
|
||||||
|
|
||||||
|
It will. Someone else's markup is not a contract.
|
||||||
|
|
||||||
|
Start with the CLI, which drives the real browser and prints what came back:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run resolve -- 'https://www.tiktok.com/@user/video/123'
|
||||||
|
```
|
||||||
|
|
||||||
|
If that fails, ask the page what it actually served:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run probe -- 'https://www.instagram.com/reel/ABC123/embed/captioned/'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `npm test`, which runs every adapter against captured payloads in `test/fixtures/`
|
||||||
|
without touching the network. If the fixtures pass but the CLI fails, the shape changed
|
||||||
|
upstream — capture a new payload and work from the diff.
|
||||||
|
|
||||||
|
A failed resolve is never a blank error page: the failure card carries the platform, the
|
||||||
|
original URL and the copy button, so a broken adapter still leaves the link one tap away.
|
||||||
|
|
||||||
|
Known rough edges:
|
||||||
|
|
||||||
|
- **Instagram is the least reliable.** It only ships the structured payload some of the
|
||||||
|
time. When it doesn't, the rendered DOM carries single images and reels fine, but a
|
||||||
|
carousel will come back as its first image only.
|
||||||
|
- **TikTok sometimes answers with a slider puzzle** instead of the post. You get handed
|
||||||
|
the puzzle rather than an error — see below.
|
||||||
|
|
||||||
|
## Verification puzzles
|
||||||
|
|
||||||
|
TikTok will occasionally answer with a "drag the slider to fit the puzzle" challenge
|
||||||
|
instead of the post. Rather than reporting that as a failure, antisocial gives you the
|
||||||
|
puzzle to solve.
|
||||||
|
|
||||||
|
The page showing it stays open in the server's browser and gets parked. You are
|
||||||
|
redirected to `/challenge/<id>`, which streams screenshots of just the puzzle and
|
||||||
|
forwards your pointer events back to be replayed onto that page. Solve it, and you land
|
||||||
|
back on the post.
|
||||||
|
|
||||||
|
What makes this work is that the cookie the challenge issues lands in the shared browser
|
||||||
|
context, so it is not just this post that unblocks — the next few are fine too. And
|
||||||
|
because the drag being replayed is your actual drag, at your actual timing, the movement
|
||||||
|
looks like what it is.
|
||||||
|
|
||||||
|
Parked pages hold a browser tab but no concurrency permit, so one waiting on you never
|
||||||
|
blocks anything else. At most three are kept, and each expires after five minutes.
|
||||||
|
|
||||||
|
There is a "give up" button, which closes the tab and leaves you the original link.
|
||||||
|
|
||||||
|
Login walls are a different thing and are not passed through: Instagram asking you to
|
||||||
|
sign in cannot be solved without an account, so those degrade to whatever the post's
|
||||||
|
link preview offers.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Needs Node 22+. Local container builds use Apple `container`, which is arm64 native and
|
||||||
|
therefore the same architecture as Kallone.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npx playwright install chromium # once
|
||||||
|
npm run dev # http://localhost:8080
|
||||||
|
|
||||||
|
npm test # adapters against captured payloads, no network
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
container build --tag antisocial:dev .
|
||||||
|
container run --rm --publish 8080:8080 antisocial:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no ESLint: TypeScript 7 is a year ahead of what `typescript-eslint` supports,
|
||||||
|
and `tsc` with `strict`, `noUnusedLocals` and `noUncheckedIndexedAccess` covers the
|
||||||
|
ground for a project this size.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Everything has a working default; the container needs none of it set.
|
||||||
|
|
||||||
|
| Variable | Default | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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 |
|
||||||
|
| `MAX_CONCURRENT` | `2` | page loads at once; the rest queue |
|
||||||
|
| `NAVIGATION_TIMEOUT_MS` | `20000` | |
|
||||||
|
| `RESOLVE_TIMEOUT_MS` | `30000` | whole resolve, including extraction |
|
||||||
|
| `CACHE_TTL_MS` / `CACHE_MAX` | `3600000` / `200` | resolved posts, in memory |
|
||||||
|
| `MEDIA_TOKEN_TTL_MS` | `21600000` | how long a `/m/` reference stays valid |
|
||||||
|
| `PUBLIC_ORIGIN` | `https://antisocial.unsupervised.studio` | only used to print the rules on `/` |
|
||||||
|
| `LOG_LEVEL` | `info` | |
|
||||||
|
|
||||||
|
## Adding a platform
|
||||||
|
|
||||||
|
One file in `src/platforms/`, one entry in the table in `src/platforms/index.ts`. The
|
||||||
|
adapter gets a `Page` and returns a `Post` (`src/types.ts`); everything downstream —
|
||||||
|
proxying, rendering, caching, the error card — already knows what to do with one.
|
||||||
|
|
||||||
|
Set `textPosition` to `'above'` for platforms that lead with words and `'below'` for the
|
||||||
|
ones that lead with pictures.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Dump what a platform's page is actually serving us.
|
||||||
|
*
|
||||||
|
* npm run probe -- 'https://www.instagram.com/reel/ABC123/embed/captioned/'
|
||||||
|
*
|
||||||
|
* `npm run resolve` answers "did it work". This answers "why not": which
|
||||||
|
* payload keys are present, what the DOM ended up with, whether we are
|
||||||
|
* looking at a challenge or a login wall. Every adapter fix so far started
|
||||||
|
* here.
|
||||||
|
*
|
||||||
|
* Pass a URL exactly as the adapter would load it — for Instagram that is
|
||||||
|
* usually the `/embed/captioned/` form, not the post URL.
|
||||||
|
*/
|
||||||
|
import { shutdown, withPage } from '../src/browser/pool.ts';
|
||||||
|
|
||||||
|
const url = process.argv[2];
|
||||||
|
if (!url) {
|
||||||
|
console.error("usage: npm run probe -- '<url>' [waitMs]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const waitMs = Number(process.argv[3] ?? 5000);
|
||||||
|
|
||||||
|
/** The keys each adapter reaches for, so a missing one is obvious. */
|
||||||
|
const KEYS = [
|
||||||
|
'shortcode_media',
|
||||||
|
'edge_sidecar_to_children',
|
||||||
|
'image_versions2',
|
||||||
|
'video_versions',
|
||||||
|
'carousel_media',
|
||||||
|
'__UNIVERSAL_DATA_FOR_REHYDRATION__',
|
||||||
|
'itemStruct',
|
||||||
|
'imagePost',
|
||||||
|
'getPostThread',
|
||||||
|
'tweet-result',
|
||||||
|
];
|
||||||
|
|
||||||
|
await withPage(async ({ page }) => {
|
||||||
|
// Only the calls that might carry the post. Bundle and asset traffic
|
||||||
|
// drowns out the one response worth seeing.
|
||||||
|
const INTERESTING = /api|graphql|xrpc|syndication|tweet-result|\.json(\?|$)/;
|
||||||
|
const NOISE = /rsrc\.php|\/static\/|static\.|\.css|\.woff|\.svg|\.png|\.jpg/;
|
||||||
|
|
||||||
|
const responses: string[] = [];
|
||||||
|
page.on('response', (r) => {
|
||||||
|
const u = r.url();
|
||||||
|
if (INTERESTING.test(u) && !NOISE.test(u)) {
|
||||||
|
responses.push(`${r.status()} ${u.slice(0, 130)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
|
console.log('status ', response?.status());
|
||||||
|
console.log('landed ', page.url());
|
||||||
|
|
||||||
|
await page.waitForTimeout(waitMs);
|
||||||
|
|
||||||
|
const found = await page.evaluate(
|
||||||
|
({ keys, selectors }: { keys: string[]; selectors: string }) => {
|
||||||
|
const scripts = [...document.querySelectorAll('script')].map((s) => s.textContent ?? '');
|
||||||
|
const all = scripts.join('');
|
||||||
|
return {
|
||||||
|
title: document.title,
|
||||||
|
keys: keys.filter((k) => all.includes(k)),
|
||||||
|
scripts: scripts.filter((s) => s.length > 256).map((s) => s.length),
|
||||||
|
videos: [...document.querySelectorAll('video')].map((v) => ({
|
||||||
|
src: (v.currentSrc || v.src).slice(0, 100),
|
||||||
|
poster: v.poster.slice(0, 80),
|
||||||
|
})),
|
||||||
|
images: [...document.querySelectorAll('img')].length,
|
||||||
|
og: Object.fromEntries(
|
||||||
|
[...document.querySelectorAll('meta[property^="og:"]')].map((m) => [
|
||||||
|
m.getAttribute('property'),
|
||||||
|
(m.getAttribute('content') ?? '').slice(0, 90),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
challenge: !!document.querySelector(selectors),
|
||||||
|
body: (document.body?.innerText ?? '').slice(0, 240),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ keys: KEYS, selectors: '[id*="captcha"], [class*="captcha_verify"]' },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('title ', found.title);
|
||||||
|
console.log('keys ', found.keys.length ? found.keys.join(', ') : '(none of the ones we read)');
|
||||||
|
console.log('scripts ', found.scripts.join(', '));
|
||||||
|
console.log('videos ', JSON.stringify(found.videos));
|
||||||
|
console.log('images ', found.images);
|
||||||
|
console.log('og ', JSON.stringify(found.og, null, 1));
|
||||||
|
console.log('challenge', found.challenge);
|
||||||
|
console.log('body ', JSON.stringify(found.body));
|
||||||
|
console.log('traffic ');
|
||||||
|
for (const line of responses.slice(0, 20)) console.log(' ', line);
|
||||||
|
});
|
||||||
|
|
||||||
|
await shutdown();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Resolve one URL against the real browser and print the Post as JSON.
|
||||||
|
*
|
||||||
|
* npm run resolve -- 'https://www.tiktok.com/@user/video/123'
|
||||||
|
*
|
||||||
|
* This is the fastest way to find out whether a platform still works, and
|
||||||
|
* the first thing to reach for when one stops.
|
||||||
|
*/
|
||||||
|
import { shutdown } from '../src/browser/pool.ts';
|
||||||
|
import { originalUrlFor, platformForUrl } from '../src/platforms/index.ts';
|
||||||
|
import { resolvePost } from '../src/resolve.ts';
|
||||||
|
|
||||||
|
const input = process.argv[2];
|
||||||
|
if (!input) {
|
||||||
|
console.error('usage: npm run resolve -- <post url>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = platformForUrl(input);
|
||||||
|
if (!spec) {
|
||||||
|
console.error(`No adapter handles ${input}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(input);
|
||||||
|
const pathRest = parsed.pathname.replace(/^\/+/, '');
|
||||||
|
const originalUrl = originalUrlFor(spec, pathRest, parsed.search);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = await resolvePost(spec, originalUrl, pathRest);
|
||||||
|
console.log(JSON.stringify(post, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await shutdown();
|
||||||
|
}
|
||||||
Generated
+1360
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "antisocial",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Reads social posts back to you without the app.",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"dev": "node --watch --experimental-strip-types src/server.ts",
|
||||||
|
"test": "node --test --experimental-strip-types \"test/**/*.test.ts\"",
|
||||||
|
"resolve": "node --experimental-strip-types bin/resolve.ts",
|
||||||
|
"probe": "node --experimental-strip-types bin/probe.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/static": "10.1.3",
|
||||||
|
"fastify": "5.12.1",
|
||||||
|
"lru-cache": "11.5.2",
|
||||||
|
"playwright": "1.62.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "26.3.0",
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f6f6f7;
|
||||||
|
--card: #ffffff;
|
||||||
|
--ink: #16181c;
|
||||||
|
--ink-dim: #5b6470;
|
||||||
|
--line: #e2e4e8;
|
||||||
|
--accent-ink: #ffffff;
|
||||||
|
--radius: 14px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0d0e11;
|
||||||
|
--card: #16181d;
|
||||||
|
--ink: #e9ebee;
|
||||||
|
--ink-dim: #949cab;
|
||||||
|
--line: #262a31;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: max(12px, env(safe-area-inset-top)) 12px calc(24px + env(safe-area-inset-bottom));
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
main { max-width: 680px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.post {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- header ---------- */
|
||||||
|
|
||||||
|
.post__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge { display: inline-flex; align-items: center; gap: 8px; flex: none; }
|
||||||
|
|
||||||
|
.badge__mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge__label { font-size: 13px; font-weight: 600; letter-spacing: 0.01em; }
|
||||||
|
|
||||||
|
.who { display: flex; align-items: center; gap: 8px; min-width: 0; margin-left: auto; }
|
||||||
|
.who__avatar { width: 28px; height: 28px; border-radius: 50%; flex: none; }
|
||||||
|
.who__names { display: flex; flex-direction: column; min-width: 0; text-align: right; }
|
||||||
|
.who__name { font-size: 13px; font-weight: 600; }
|
||||||
|
.who__name, .who__handle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.who__handle { font-size: 12px; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
/* ---------- text ---------- */
|
||||||
|
|
||||||
|
.text {
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text a { color: inherit; text-decoration-color: var(--ink-dim); text-underline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ---------- media ---------- */
|
||||||
|
|
||||||
|
.media { border-block: 1px solid var(--line); background: #000; }
|
||||||
|
.post__head + .media, .text + .media { border-top: 1px solid var(--line); }
|
||||||
|
|
||||||
|
.media__bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__count { font-size: 12px; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.media__views { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
|
||||||
|
|
||||||
|
.media__view {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__view[aria-pressed="true"] { background: var(--ink); color: var(--card); }
|
||||||
|
|
||||||
|
/* Swipe is the default, and needs no JavaScript to work. */
|
||||||
|
.media__rail {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media__rail::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.item {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
scroll-snap-align: center;
|
||||||
|
scroll-snap-stop: always;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item img, .item video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 78vh;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item__note {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #cfd4dc;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media[data-view="grid"] .media__rail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 2px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media[data-view="grid"] .item { aspect-ratio: 1 / 1 !important; cursor: zoom-in; }
|
||||||
|
.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__note { display: none; }
|
||||||
|
|
||||||
|
/* ---------- footer ---------- */
|
||||||
|
|
||||||
|
.post__foot { padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.post__foot time { font-size: 12px; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
.original { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
|
||||||
|
|
||||||
|
.copy, .original__open {
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy { background: var(--ink); color: var(--card); border-color: var(--ink); }
|
||||||
|
.copy[data-copied] { background: #1a7f4b; border-color: #1a7f4b; }
|
||||||
|
.original__open { background: transparent; color: var(--ink); }
|
||||||
|
|
||||||
|
.original__url { flex-basis: 100%; margin: 0; }
|
||||||
|
.original__url code {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- error and index ---------- */
|
||||||
|
|
||||||
|
.error__heading { font-size: 18px; margin: 14px; }
|
||||||
|
.error__detail { margin: 0 14px 14px; color: var(--ink-dim); font-size: 14px; }
|
||||||
|
.post--error .post__head:empty { display: none; }
|
||||||
|
|
||||||
|
.post--index { padding: 18px; }
|
||||||
|
.index__title { margin: 0; font-size: 20px; }
|
||||||
|
.index__lede { color: var(--ink-dim); font-size: 14px; }
|
||||||
|
.index__table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.index__table th { text-align: left; color: var(--ink-dim); font-weight: 600; padding: 6px 8px 6px 0; }
|
||||||
|
.index__table td { padding: 8px 8px 8px 0; border-top: 1px solid var(--line); vertical-align: middle; }
|
||||||
|
.index__table code { overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
/* ---------- verification puzzle ---------- */
|
||||||
|
|
||||||
|
.challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); }
|
||||||
|
|
||||||
|
.challenge__stage {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
background: #000;
|
||||||
|
border-block: 1px solid var(--line);
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge__stage img {
|
||||||
|
max-width: 100%;
|
||||||
|
/* The puzzle is a drag target, so the browser must not claim the gesture
|
||||||
|
for scrolling or image-dragging. */
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.challenge__stage img:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
.challenge__status {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e9ebee;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
// Progressive enhancement only. Without this file the page still shows the
|
||||||
|
// media in a swipeable rail and the original URL as selectable text.
|
||||||
|
|
||||||
|
const VIEW_KEY = 'antisocial:view';
|
||||||
|
|
||||||
|
function setupCopy() {
|
||||||
|
for (const button of document.querySelectorAll('.copy')) {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const url = button.dataset.url;
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
} catch {
|
||||||
|
// No clipboard permission, or an insecure context. Select the URL
|
||||||
|
// instead so one keystroke finishes the job.
|
||||||
|
const code = button.closest('.original')?.querySelector('code');
|
||||||
|
if (code) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(code);
|
||||||
|
const selection = getSelection();
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.dataset.copied = 'true';
|
||||||
|
button.querySelector('.copy__idle')?.setAttribute('hidden', '');
|
||||||
|
button.querySelector('.copy__done')?.removeAttribute('hidden');
|
||||||
|
setTimeout(() => {
|
||||||
|
delete button.dataset.copied;
|
||||||
|
button.querySelector('.copy__idle')?.removeAttribute('hidden');
|
||||||
|
button.querySelector('.copy__done')?.setAttribute('hidden', '');
|
||||||
|
}, 1600);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMedia() {
|
||||||
|
const media = document.querySelector('.media');
|
||||||
|
if (!media) return;
|
||||||
|
|
||||||
|
const rail = media.querySelector('.media__rail');
|
||||||
|
const items = [...media.querySelectorAll('.item')];
|
||||||
|
const at = media.querySelector('.media__at');
|
||||||
|
const buttons = [...media.querySelectorAll('.media__view')];
|
||||||
|
if (!rail || items.length < 2) return;
|
||||||
|
|
||||||
|
function applyView(view, remember) {
|
||||||
|
media.dataset.view = view;
|
||||||
|
for (const button of buttons) {
|
||||||
|
button.setAttribute('aria-pressed', String(button.dataset.view === view));
|
||||||
|
}
|
||||||
|
if (remember) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VIEW_KEY, view);
|
||||||
|
} catch {
|
||||||
|
// Private browsing. The choice just won't survive the page.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored = null;
|
||||||
|
try {
|
||||||
|
stored = localStorage.getItem(VIEW_KEY);
|
||||||
|
} catch {
|
||||||
|
stored = null;
|
||||||
|
}
|
||||||
|
applyView(stored === 'grid' ? 'grid' : 'swipe', false);
|
||||||
|
|
||||||
|
for (const button of buttons) {
|
||||||
|
button.addEventListener('click', () => applyView(button.dataset.view, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tapping a thumbnail goes back to the swiper, at that image.
|
||||||
|
for (const item of items) {
|
||||||
|
item.addEventListener('click', (event) => {
|
||||||
|
if (media.dataset.view !== 'grid') return;
|
||||||
|
if (event.target.closest('video')) return;
|
||||||
|
applyView('swipe', true);
|
||||||
|
item.scrollIntoView({ inline: 'center', block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the counter honest as the rail scrolls.
|
||||||
|
if (at) {
|
||||||
|
let frame = 0;
|
||||||
|
rail.addEventListener('scroll', () => {
|
||||||
|
if (frame) return;
|
||||||
|
frame = requestAnimationFrame(() => {
|
||||||
|
frame = 0;
|
||||||
|
const index = Math.round(rail.scrollLeft / rail.clientWidth);
|
||||||
|
at.textContent = String(Math.min(items.length, Math.max(1, index + 1)));
|
||||||
|
});
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCopy();
|
||||||
|
setupMedia();
|
||||||
|
|
||||||
|
// --- Verification puzzles -------------------------------------------------
|
||||||
|
//
|
||||||
|
// The puzzle is rendered in the browser on the server, not this one. So the
|
||||||
|
// page shows a live screenshot of it and forwards pointer events back to be
|
||||||
|
// replayed. Solving it leaves the cookie in that browser, and the post then
|
||||||
|
// loads normally.
|
||||||
|
|
||||||
|
function setupChallenge() {
|
||||||
|
const article = document.querySelector('[data-challenge]');
|
||||||
|
if (!article) return;
|
||||||
|
|
||||||
|
const id = article.dataset.challenge;
|
||||||
|
const frame = document.getElementById('frame');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const stage = document.getElementById('stage');
|
||||||
|
const giveUp = document.getElementById('give-up');
|
||||||
|
|
||||||
|
let box = parseBox(stage.dataset.box);
|
||||||
|
let dragging = false;
|
||||||
|
let lastSent = 0;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
function parseBox(value) {
|
||||||
|
const parts = (value || '').split(',').map(Number);
|
||||||
|
return parts.length === 4 && parts.every(Number.isFinite)
|
||||||
|
? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-paced: the next frame is only asked for once the last one painted,
|
||||||
|
// so a slow screenshot slows the stream instead of queueing behind itself.
|
||||||
|
function pump() {
|
||||||
|
if (stopped) return;
|
||||||
|
frame.src = `/challenge/${id}/frame?t=${Date.now()}`;
|
||||||
|
}
|
||||||
|
frame.addEventListener('load', () => {
|
||||||
|
status.hidden = true;
|
||||||
|
setTimeout(pump, dragging ? 60 : 220);
|
||||||
|
});
|
||||||
|
frame.addEventListener('error', () => {
|
||||||
|
if (!stopped) setTimeout(pump, 1000);
|
||||||
|
});
|
||||||
|
pump();
|
||||||
|
|
||||||
|
function toPageCoords(event) {
|
||||||
|
const rect = frame.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) return null;
|
||||||
|
const fx = (event.clientX - rect.left) / rect.width;
|
||||||
|
const fy = (event.clientY - rect.top) / rect.height;
|
||||||
|
// Without a clip box the screenshot is the whole viewport, whose size
|
||||||
|
// the server knows; fall back to the image's own pixel dimensions.
|
||||||
|
const region = box ?? { x: 0, y: 0, width: frame.naturalWidth / 2, height: frame.naturalHeight / 2 };
|
||||||
|
return { x: region.x + fx * region.width, y: region.y + fy * region.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(type, point) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/challenge/${id}/input`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type, x: point.x, y: point.y }),
|
||||||
|
});
|
||||||
|
if (response.status === 410) finish();
|
||||||
|
} catch {
|
||||||
|
// A dropped event during a drag is survivable; the next one follows.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.addEventListener('pointerdown', (event) => {
|
||||||
|
const point = toPageCoords(event);
|
||||||
|
if (!point) return;
|
||||||
|
event.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
frame.setPointerCapture(event.pointerId);
|
||||||
|
void send('down', point);
|
||||||
|
});
|
||||||
|
|
||||||
|
frame.addEventListener('pointermove', (event) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastSent < 25) return;
|
||||||
|
lastSent = now;
|
||||||
|
const point = toPageCoords(event);
|
||||||
|
if (point) void send('move', point);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const type of ['pointerup', 'pointercancel']) {
|
||||||
|
frame.addEventListener(type, async (event) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = false;
|
||||||
|
const point = toPageCoords(event);
|
||||||
|
if (point) await send('up', point);
|
||||||
|
status.hidden = false;
|
||||||
|
status.textContent = 'Checking…';
|
||||||
|
void poll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
for (let attempt = 0; attempt < 12 && !stopped; attempt += 1) {
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/challenge/${id}/state`);
|
||||||
|
if (response.status === 410) return finish();
|
||||||
|
const state = await response.json();
|
||||||
|
if (state.box) box = state.box;
|
||||||
|
if (state.solved) return finish();
|
||||||
|
} catch {
|
||||||
|
// Keep trying; the page may be mid-navigation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status.textContent = 'Still there — have another go.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
status.hidden = false;
|
||||||
|
status.textContent = 'Got it. Loading the post…';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/challenge/${id}/done`, { method: 'POST' });
|
||||||
|
const { returnTo } = await response.json();
|
||||||
|
location.href = returnTo || '/';
|
||||||
|
} catch {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
giveUp?.addEventListener('click', () => {
|
||||||
|
stopped = true;
|
||||||
|
void fetch(`/challenge/${id}/done`, { method: 'POST' }).finally(() => {
|
||||||
|
article.querySelector('.original__url')?.scrollIntoView({ block: 'center' });
|
||||||
|
status.textContent = 'Gave up. The original link is below.';
|
||||||
|
status.hidden = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupChallenge();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#16181c"/><path d="M9 22c3.5-8 10.5-8 14 0" stroke="#f6f6f7" stroke-width="2.5" fill="none" stroke-linecap="round"/><circle cx="12" cy="12" r="2" fill="#f6f6f7"/><circle cx="20" cy="12" r="2" fill="#f6f6f7"/></svg>
|
||||||
|
After Width: | Height: | Size: 316 B |
@@ -0,0 +1,87 @@
|
|||||||
|
import type { Page, Response } from 'playwright';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for the page's own API responses going past and hand back the first
|
||||||
|
* one that matches. Reading the JSON a platform serves its own front end is
|
||||||
|
* far steadier than scraping the DOM it renders from that JSON.
|
||||||
|
*
|
||||||
|
* Attach this *before* navigating.
|
||||||
|
*/
|
||||||
|
export function captureJson<T = unknown>(
|
||||||
|
page: Page,
|
||||||
|
matches: (url: string, response: Response) => boolean,
|
||||||
|
): { wait: (timeoutMs: number) => Promise<T | undefined> } {
|
||||||
|
let settled: T | undefined;
|
||||||
|
const listeners: Array<(value: T) => void> = [];
|
||||||
|
|
||||||
|
const onResponse = (response: Response): void => {
|
||||||
|
if (settled !== undefined) return;
|
||||||
|
if (!matches(response.url(), response)) return;
|
||||||
|
void response
|
||||||
|
.json()
|
||||||
|
.then((body: T) => {
|
||||||
|
if (settled !== undefined) return;
|
||||||
|
settled = body;
|
||||||
|
for (const notify of listeners.splice(0)) notify(body);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// A matching response that isn't JSON, or was aborted. Keep waiting
|
||||||
|
// for a better one rather than failing the whole resolve.
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
page.on('response', onResponse);
|
||||||
|
|
||||||
|
return {
|
||||||
|
async wait(timeoutMs: number): Promise<T | undefined> {
|
||||||
|
if (settled !== undefined) return settled;
|
||||||
|
return new Promise<T | undefined>((resolve) => {
|
||||||
|
const timer = setTimeout(() => resolve(undefined), timeoutMs);
|
||||||
|
listeners.push((value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an inline `<script>` payload out of the DOM.
|
||||||
|
*
|
||||||
|
* Waits for the element rather than reading straight after navigation: a
|
||||||
|
* platform that bounces through an interstitial first will have an empty
|
||||||
|
* document at `domcontentloaded`.
|
||||||
|
*/
|
||||||
|
export async function readInlineJson<T = unknown>(
|
||||||
|
page: Page,
|
||||||
|
selector: string,
|
||||||
|
timeoutMs = 15_000,
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
const raw = await page
|
||||||
|
.locator(selector)
|
||||||
|
.first()
|
||||||
|
.textContent({ timeout: timeoutMs })
|
||||||
|
.catch(() => null);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every `<meta property="og:*">` on the page, as a plain object. */
|
||||||
|
export async function readOpenGraph(page: Page): Promise<Record<string, string>> {
|
||||||
|
return page
|
||||||
|
.evaluate(() => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const el of document.querySelectorAll('meta[property], meta[name]')) {
|
||||||
|
const key = el.getAttribute('property') ?? el.getAttribute('name');
|
||||||
|
const value = el.getAttribute('content');
|
||||||
|
if (key && value && !(key in out)) out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})
|
||||||
|
.catch(() => ({}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||||
|
import { config } from '../config.ts';
|
||||||
|
import { Semaphore } from './queue.ts';
|
||||||
|
|
||||||
|
export type PageSession = {
|
||||||
|
page: Page;
|
||||||
|
/**
|
||||||
|
* Keep this page open after the callback returns or throws, and hand
|
||||||
|
* responsibility for closing it to the caller. Used to park a page that is
|
||||||
|
* showing a challenge so the viewer can finish it by hand.
|
||||||
|
*/
|
||||||
|
detach: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pure telemetry and advertising. Dropping these is faster and quieter. */
|
||||||
|
const BLOCKED_HOSTS = [
|
||||||
|
'google-analytics.com',
|
||||||
|
'googletagmanager.com',
|
||||||
|
'googlesyndication.com',
|
||||||
|
'doubleclick.net',
|
||||||
|
'scorecardresearch.com',
|
||||||
|
'quantserve.com',
|
||||||
|
'analytics.tiktok.com',
|
||||||
|
'mcs.tiktokv.com',
|
||||||
|
'log-va.tiktokv.com',
|
||||||
|
'log.tiktokv.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
const HEAVY_RESOURCE_TYPES = new Set(['font', 'media', 'image']);
|
||||||
|
|
||||||
|
let browser: Browser | undefined;
|
||||||
|
let context: BrowserContext | undefined;
|
||||||
|
let starting: Promise<BrowserContext> | undefined;
|
||||||
|
let consecutiveFailures = 0;
|
||||||
|
let lastStateSave = 0;
|
||||||
|
|
||||||
|
const gate = new Semaphore(config.maxConcurrent);
|
||||||
|
|
||||||
|
function statePath(): string {
|
||||||
|
return join(config.profileDir, 'state.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright's Chromium announces itself as `HeadlessChrome`, which is the
|
||||||
|
* one thing about this setup that does not look like someone opening a link.
|
||||||
|
* The rest of the string is left exactly as the bundled build reports it, so
|
||||||
|
* it stays honest and tracks Playwright upgrades on its own.
|
||||||
|
*/
|
||||||
|
function userAgentFor(b: Browser): string {
|
||||||
|
const version = b.version();
|
||||||
|
return `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStorageState(): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(statePath(), 'utf8');
|
||||||
|
JSON.parse(raw); // reject a truncated file rather than failing the launch
|
||||||
|
return statePath();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(): Promise<BrowserContext> {
|
||||||
|
await mkdir(config.profileDir, { recursive: true });
|
||||||
|
|
||||||
|
const launched = await chromium.launch({
|
||||||
|
args: [
|
||||||
|
// The container is unprivileged and single-user; the kernel sandbox
|
||||||
|
// cannot start without extra capabilities we would rather not grant.
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
'--mute-audio',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
launched.on('disconnected', () => {
|
||||||
|
if (browser === launched) {
|
||||||
|
browser = undefined;
|
||||||
|
context = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const storageState = await loadStorageState();
|
||||||
|
const created = await launched.newContext({
|
||||||
|
userAgent: userAgentFor(launched),
|
||||||
|
locale: 'en-CA',
|
||||||
|
timezoneId: 'America/Halifax',
|
||||||
|
viewport: { width: 1280, height: 900 },
|
||||||
|
// Retina-sized assets are what these platforms serve a real laptop.
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
extraHTTPHeaders: { 'Accept-Language': 'en-CA,en;q=0.9' },
|
||||||
|
...(storageState ? { storageState } : {}),
|
||||||
|
});
|
||||||
|
created.setDefaultNavigationTimeout(config.navigationTimeoutMs);
|
||||||
|
created.setDefaultTimeout(config.navigationTimeoutMs);
|
||||||
|
|
||||||
|
browser = launched;
|
||||||
|
context = created;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContext(): Promise<BrowserContext> {
|
||||||
|
if (context) return context;
|
||||||
|
starting ??= start().finally(() => {
|
||||||
|
starting = undefined;
|
||||||
|
});
|
||||||
|
return starting;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cookies and localStorage are persisted so the browser accumulates the
|
||||||
|
* same everyday state a real one does. Throttled: this is a nicety, not a
|
||||||
|
* thing worth writing on every request. */
|
||||||
|
async function saveState(): Promise<void> {
|
||||||
|
if (!context) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastStateSave < 30_000) return;
|
||||||
|
lastStateSave = now;
|
||||||
|
try {
|
||||||
|
const state = await context.storageState();
|
||||||
|
await writeFile(statePath(), JSON.stringify(state), 'utf8');
|
||||||
|
} catch {
|
||||||
|
// Losing the profile costs nothing but a fresh set of cookies.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function warmUp(): Promise<void> {
|
||||||
|
await getContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReady(): boolean {
|
||||||
|
return context !== undefined && browser?.isConnected() === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueDepth(): number {
|
||||||
|
return gate.queueDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdown(): Promise<void> {
|
||||||
|
const b = browser;
|
||||||
|
browser = undefined;
|
||||||
|
context = undefined;
|
||||||
|
await b?.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restart(): Promise<void> {
|
||||||
|
consecutiveFailures = 0;
|
||||||
|
await shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` against a fresh page, queued behind the concurrency gate. The
|
||||||
|
* page is always closed, and two consecutive failures recycle Chromium
|
||||||
|
* rather than leaving a wedged browser to fail every later request.
|
||||||
|
*/
|
||||||
|
export async function withPage<T>(fn: (session: PageSession) => Promise<T>): Promise<T> {
|
||||||
|
return gate.run(async () => {
|
||||||
|
const ctx = await getContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
let detached = false;
|
||||||
|
|
||||||
|
await page.route('**/*', (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = request.url();
|
||||||
|
|
||||||
|
if (BLOCKED_HOSTS.some((host) => url.includes(host))) {
|
||||||
|
return route.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the URLs matter here: the bytes are fetched later, by the
|
||||||
|
// viewer's browser, through our own media proxy. Aborting still leaves
|
||||||
|
// the `src` attribute in the DOM for the adapters to read.
|
||||||
|
if (HEAVY_RESOURCE_TYPES.has(request.resourceType())) {
|
||||||
|
return route.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fn({ page, detach: () => { detached = true; } });
|
||||||
|
consecutiveFailures = 0;
|
||||||
|
void saveState();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (detached) throw error;
|
||||||
|
consecutiveFailures += 1;
|
||||||
|
if (consecutiveFailures >= 2) await restart();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (!detached) await page.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `Cookie` header the browser context would send to `url`, if any.
|
||||||
|
* Handed to the media proxy so its fetches look like the browser's. */
|
||||||
|
export async function cookieHeaderFor(url: string): Promise<string | undefined> {
|
||||||
|
if (!context) return undefined;
|
||||||
|
const cookies = await context.cookies(url).catch(() => []);
|
||||||
|
if (cookies.length === 0) return undefined;
|
||||||
|
return cookies.map((c) => `${c.name}=${c.value}`).join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user agent the context is running with, so proxied fetches match. */
|
||||||
|
export function currentUserAgent(): string | undefined {
|
||||||
|
return browser ? userAgentFor(browser) : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* A counting semaphore. Chromium is the memory ceiling on this box, so page
|
||||||
|
* loads queue behind a fixed number of permits rather than opening an
|
||||||
|
* unbounded number of tabs when several links arrive at once.
|
||||||
|
*/
|
||||||
|
export class Semaphore {
|
||||||
|
#permits: number;
|
||||||
|
readonly #waiters: Array<() => void> = [];
|
||||||
|
|
||||||
|
constructor(permits: number) {
|
||||||
|
if (permits < 1) throw new Error('Semaphore needs at least one permit');
|
||||||
|
this.#permits = permits;
|
||||||
|
}
|
||||||
|
|
||||||
|
get queueDepth(): number {
|
||||||
|
return this.#waiters.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
await this.#acquire();
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
this.#release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#acquire(): Promise<void> {
|
||||||
|
if (this.#permits > 0) {
|
||||||
|
this.#permits -= 1;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve) => this.#waiters.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
#release(): void {
|
||||||
|
const next = this.#waiters.shift();
|
||||||
|
if (next) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#permits += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject with a clear message rather than hanging if a resolve overruns. */
|
||||||
|
export async function withTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
ms: number,
|
||||||
|
label: string,
|
||||||
|
): Promise<T> {
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import type { Page } from 'playwright';
|
||||||
|
import type { PlatformId } from '../types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages parked mid-challenge, waiting for a person to finish them.
|
||||||
|
*
|
||||||
|
* A parked page is outside the pool's normal lifecycle: it holds no
|
||||||
|
* concurrency permit, so parking one never blocks anything else, but it does
|
||||||
|
* hold a browser tab, hence the cap and the expiry.
|
||||||
|
*/
|
||||||
|
export type Challenge = {
|
||||||
|
id: string;
|
||||||
|
page: Page;
|
||||||
|
platform: PlatformId;
|
||||||
|
platformLabel: string;
|
||||||
|
originalUrl: string;
|
||||||
|
/** Where to send the viewer once the puzzle is done. */
|
||||||
|
returnTo: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The selectors a challenge tends to live in, biggest hint first. */
|
||||||
|
export const CHALLENGE_SELECTORS = '[id*="captcha"], [class*="captcha_verify"], [class*="captcha-verify"]';
|
||||||
|
|
||||||
|
const MAX_PARKED = 3;
|
||||||
|
const TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
const parked = new Map<string, Challenge>();
|
||||||
|
|
||||||
|
async function close(challenge: Challenge): Promise<void> {
|
||||||
|
parked.delete(challenge.id);
|
||||||
|
await challenge.page.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sweep(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const challenge of [...parked.values()]) {
|
||||||
|
if (challenge.expiresAt <= now) void close(challenge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function park(options: {
|
||||||
|
page: Page;
|
||||||
|
platform: PlatformId;
|
||||||
|
platformLabel: string;
|
||||||
|
originalUrl: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
sweep();
|
||||||
|
|
||||||
|
// Never sit on more than a few tabs. The oldest is the least likely to
|
||||||
|
// still have someone looking at it.
|
||||||
|
while (parked.size >= MAX_PARKED) {
|
||||||
|
const oldest = [...parked.values()].sort((a, b) => a.expiresAt - b.expiresAt)[0];
|
||||||
|
if (!oldest) break;
|
||||||
|
await close(oldest);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomBytes(9).toString('base64url');
|
||||||
|
parked.set(id, { ...options, id, returnTo: '/', expiresAt: Date.now() + TTL_MS });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get(id: string): Challenge | undefined {
|
||||||
|
sweep();
|
||||||
|
const challenge = parked.get(id);
|
||||||
|
if (!challenge) return undefined;
|
||||||
|
// Someone is still working on it.
|
||||||
|
challenge.expiresAt = Date.now() + TTL_MS;
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setReturnTo(id: string, returnTo: string): void {
|
||||||
|
const challenge = parked.get(id);
|
||||||
|
if (challenge) challenge.returnTo = returnTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function release(id: string): Promise<string | undefined> {
|
||||||
|
const challenge = parked.get(id);
|
||||||
|
if (!challenge) return undefined;
|
||||||
|
const { returnTo } = challenge;
|
||||||
|
await close(challenge);
|
||||||
|
return returnTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parkedCount(): number {
|
||||||
|
sweep();
|
||||||
|
return parked.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Has the puzzle gone away? */
|
||||||
|
export async function isSolved(challenge: Challenge): Promise<boolean> {
|
||||||
|
return challenge.page
|
||||||
|
.evaluate((selectors: string) => {
|
||||||
|
const el = document.querySelector(selectors);
|
||||||
|
if (!el) return true;
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
return style.display === 'none' || style.visibility === 'hidden';
|
||||||
|
}, CHALLENGE_SELECTORS)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Every knob is an environment variable with a working default, so the
|
||||||
|
* container runs correctly with no configuration at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function int(name: string, fallback: number): number {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw === '') return fallback;
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
throw new Error(`${name} must be an integer, got ${JSON.stringify(raw)}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(name: string, fallback: string): string {
|
||||||
|
const raw = process.env[name];
|
||||||
|
return raw === undefined || raw === '' ? fallback : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
host: str('HOST', '0.0.0.0'),
|
||||||
|
port: int('PORT', 8080),
|
||||||
|
logLevel: str('LOG_LEVEL', 'info'),
|
||||||
|
|
||||||
|
/** Where the persistent Chromium profile lives. Survives restarts so that
|
||||||
|
* cookies and dismissed consent banners accumulate the way a real
|
||||||
|
* browser's do. */
|
||||||
|
profileDir: str('PROFILE_DIR', './profile'),
|
||||||
|
|
||||||
|
/** Concurrent page loads. Chromium is the memory ceiling on this box, so
|
||||||
|
* requests queue rather than opening an unbounded number of tabs. */
|
||||||
|
maxConcurrent: int('MAX_CONCURRENT', 2),
|
||||||
|
|
||||||
|
/** Budget for a single navigation, and for a whole resolve including
|
||||||
|
* extraction. */
|
||||||
|
navigationTimeoutMs: int('NAVIGATION_TIMEOUT_MS', 20_000),
|
||||||
|
resolveTimeoutMs: int('RESOLVE_TIMEOUT_MS', 30_000),
|
||||||
|
|
||||||
|
/** Resolved posts are cached in memory only. Reloading or hitting back
|
||||||
|
* must not drive Chromium again. */
|
||||||
|
cacheTtlMs: int('CACHE_TTL_MS', 60 * 60 * 1000),
|
||||||
|
cacheMax: int('CACHE_MAX', 200),
|
||||||
|
|
||||||
|
/** How long a signed /m/ media token stays valid. Long enough to finish
|
||||||
|
* watching a video, short enough that a leaked URL goes stale. */
|
||||||
|
mediaTokenTtlMs: int('MEDIA_TOKEN_TTL_MS', 6 * 60 * 60 * 1000),
|
||||||
|
|
||||||
|
/** Public origin, used to build absolute URLs. Only needs setting if
|
||||||
|
* something ever cares about absolute links. */
|
||||||
|
publicOrigin: str('PUBLIC_ORIGIN', 'https://antisocial.unsupervised.studio'),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Config = typeof config;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { LRUCache } from 'lru-cache';
|
||||||
|
import { config } from '../config.ts';
|
||||||
|
import type { Asset } from '../types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps an opaque id to the upstream asset it stands for.
|
||||||
|
*
|
||||||
|
* A server-side registry rather than a signed self-describing token, for two
|
||||||
|
* reasons: the upstream URLs carry short-lived CDN signatures and session
|
||||||
|
* hints that have no business being in page source, and TikTok and Instagram
|
||||||
|
* need a `Cookie` header alongside the `Referer`, which would either bloat
|
||||||
|
* the token or need a persisted encryption key.
|
||||||
|
*
|
||||||
|
* The cost is that ids do not survive a restart. For a single-user app whose
|
||||||
|
* post cache is also in memory, a reload is the whole recovery story.
|
||||||
|
*/
|
||||||
|
const entries = new LRUCache<string, Asset>({
|
||||||
|
max: 4_000,
|
||||||
|
ttl: config.mediaTokenTtlMs,
|
||||||
|
updateAgeOnGet: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Register an asset and get back the path the page should point at. */
|
||||||
|
export function proxyUrlFor(asset: Asset): string {
|
||||||
|
if (asset.direct) return asset.url;
|
||||||
|
const id = randomBytes(16).toString('base64url');
|
||||||
|
entries.set(id, asset);
|
||||||
|
return `/m/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupAsset(id: string): Asset | undefined {
|
||||||
|
return entries.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registrySize(): number {
|
||||||
|
return entries.size;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { config } from '../config.ts';
|
||||||
|
import { ResolveError, type Media, type Post } from '../types.ts';
|
||||||
|
import { captureJson } from '../browser/capture.ts';
|
||||||
|
import { hostMatcher } from './url.ts';
|
||||||
|
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||||
|
|
||||||
|
const API = 'https://public.api.bsky.app/xrpc';
|
||||||
|
|
||||||
|
type Aspect = { width: number; height: number };
|
||||||
|
|
||||||
|
type Embed = {
|
||||||
|
$type?: string;
|
||||||
|
images?: Array<{ thumb?: string; fullsize?: string; alt?: string; aspectRatio?: Aspect }>;
|
||||||
|
playlist?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
aspectRatio?: Aspect;
|
||||||
|
media?: Embed;
|
||||||
|
record?: { embeds?: Embed[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
type BskyPost = {
|
||||||
|
uri?: string;
|
||||||
|
author?: { handle?: string; displayName?: string; avatar?: string };
|
||||||
|
record?: { text?: string; createdAt?: string };
|
||||||
|
embed?: Embed;
|
||||||
|
indexedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThreadResponse = { thread?: { post?: BskyPost } };
|
||||||
|
|
||||||
|
/** `profile/<actor>/post/<rkey>` is the only shape that carries a post. */
|
||||||
|
function parsePath(pathRest: string): { actor: string; rkey: string } | undefined {
|
||||||
|
const match = /^profile\/([^/]+)\/post\/([^/?#]+)/.exec(pathRest);
|
||||||
|
if (!match?.[1] || !match[2]) return undefined;
|
||||||
|
return { actor: decodeURIComponent(match[1]), rkey: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaFromEmbed(embed: Embed | undefined): Media[] {
|
||||||
|
if (!embed) return [];
|
||||||
|
|
||||||
|
// A quote post with media attached nests the real embed one level down.
|
||||||
|
if (embed.media) return mediaFromEmbed(embed.media);
|
||||||
|
|
||||||
|
if (embed.images?.length) {
|
||||||
|
return embed.images.flatMap<Media>((image) => {
|
||||||
|
const url = image.fullsize ?? image.thumb;
|
||||||
|
if (!url) return [];
|
||||||
|
return [{
|
||||||
|
kind: 'image',
|
||||||
|
url,
|
||||||
|
...(image.alt ? { alt: image.alt } : {}),
|
||||||
|
...(image.aspectRatio ?? {}),
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embed.playlist) {
|
||||||
|
return [{
|
||||||
|
kind: 'video',
|
||||||
|
url: embed.playlist,
|
||||||
|
// An HLS playlist: proxying it would mean rewriting the manifest and
|
||||||
|
// every segment URL, and Bluesky's CDN is happy to be linked directly.
|
||||||
|
hls: true,
|
||||||
|
direct: true,
|
||||||
|
...(embed.thumbnail ? { poster: { url: embed.thumbnail } } : {}),
|
||||||
|
...(embed.aspectRatio ?? {}),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A quote post carries the quoted post's media one level in. That is
|
||||||
|
// usually the thing being pointed at, so it is what to show.
|
||||||
|
const quoted = embed.record?.embeds?.[0];
|
||||||
|
if (quoted) return mediaFromEmbed(quoted);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPost(post: BskyPost, originalUrl: string): Post {
|
||||||
|
const handle = post.author?.handle ?? 'unknown';
|
||||||
|
return {
|
||||||
|
platform: 'bluesky',
|
||||||
|
platformLabel: 'Bluesky',
|
||||||
|
originalUrl,
|
||||||
|
author: {
|
||||||
|
handle: `@${handle}`,
|
||||||
|
...(post.author?.displayName ? { displayName: post.author.displayName } : {}),
|
||||||
|
...(post.author?.avatar ? { avatar: { url: post.author.avatar } } : {}),
|
||||||
|
},
|
||||||
|
...(post.record?.text ? { text: post.record.text } : {}),
|
||||||
|
textPosition: 'above',
|
||||||
|
media: mediaFromEmbed(post.embed),
|
||||||
|
...(post.record?.createdAt ? { postedAt: post.record.createdAt } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve({ page, originalUrl, pathRest }: ResolveContext): Promise<Post> {
|
||||||
|
const parsed = parsePath(pathRest);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new ResolveError('Not a Bluesky post URL', 'bluesky', originalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bluesky publishes a genuinely public API, so asking it directly beats
|
||||||
|
// booting the whole single-page app. Still the browser's network stack,
|
||||||
|
// so it carries the same identity as every other request we make.
|
||||||
|
let did = parsed.actor;
|
||||||
|
if (!did.startsWith('did:')) {
|
||||||
|
const resolved = await page.request
|
||||||
|
.get(`${API}/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(did)}`)
|
||||||
|
.then((r) => (r.ok() ? (r.json() as Promise<{ did?: string }>) : undefined))
|
||||||
|
.catch(() => undefined);
|
||||||
|
if (resolved?.did) did = resolved.did;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uri = `at://${did}/app.bsky.feed.post/${parsed.rkey}`;
|
||||||
|
const direct = await page.request
|
||||||
|
.get(`${API}/app.bsky.feed.getPostThread?depth=0&parentHeight=0&uri=${encodeURIComponent(uri)}`)
|
||||||
|
.then((r) => (r.ok() ? (r.json() as Promise<ThreadResponse>) : undefined))
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
if (direct?.thread?.post) return toPost(direct.thread.post, originalUrl);
|
||||||
|
|
||||||
|
// Fall back to loading the post the way a person would and catching the
|
||||||
|
// same call as it goes past.
|
||||||
|
// `getPostThread?` and not `getPostThreadV2?`: the app calls V2 now, whose
|
||||||
|
// shape is different, and settling on it would mean waiting for a payload
|
||||||
|
// we cannot read.
|
||||||
|
const captured = captureJson<ThreadResponse>(page, (url) => url.includes('getPostThread?'));
|
||||||
|
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
const body = await captured.wait(config.navigationTimeoutMs);
|
||||||
|
if (body?.thread?.post) return toPost(body.thread.post, originalUrl);
|
||||||
|
|
||||||
|
throw new ResolveError('Bluesky returned no post for that URL', 'bluesky', originalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bluesky: PlatformSpec = {
|
||||||
|
id: 'bluesky',
|
||||||
|
label: 'Bluesky',
|
||||||
|
prefix: 'bsky',
|
||||||
|
canonicalHost: 'bsky.app',
|
||||||
|
textPosition: 'above',
|
||||||
|
matchesHost: hostMatcher('bsky.app'),
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { PlatformSpec } from './types.ts';
|
||||||
|
import { stripTracking } from './url.ts';
|
||||||
|
import { x } from './x.ts';
|
||||||
|
import { threads } from './threads.ts';
|
||||||
|
import { instagram } from './instagram.ts';
|
||||||
|
import { tiktok } from './tiktok.ts';
|
||||||
|
import { bluesky } from './bluesky.ts';
|
||||||
|
|
||||||
|
export const platforms: readonly PlatformSpec[] = [x, threads, instagram, tiktok, bluesky];
|
||||||
|
|
||||||
|
const byPrefix = new Map(platforms.map((p) => [p.prefix, p]));
|
||||||
|
|
||||||
|
export function platformByPrefix(prefix: string): PlatformSpec | undefined {
|
||||||
|
return byPrefix.get(prefix.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route a pasted original URL to its platform. Used by bin/resolve.ts. */
|
||||||
|
export function platformForUrl(url: string): PlatformSpec | undefined {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const host = parsed.hostname.toLowerCase();
|
||||||
|
return platforms.find((p) => p.matchesHost(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn an inbound request into the canonical original URL.
|
||||||
|
*
|
||||||
|
* `/x/user/status/123?s=20` -> `https://x.com/user/status/123`
|
||||||
|
*/
|
||||||
|
export function originalUrlFor(spec: PlatformSpec, pathRest: string, search: string): string {
|
||||||
|
const rest = pathRest.replace(/^\/+/, '');
|
||||||
|
const raw = spec.buildOriginalUrl
|
||||||
|
? spec.buildOriginalUrl(rest, search)
|
||||||
|
: `https://${spec.canonicalHost}/${rest}${search}`;
|
||||||
|
return stripTracking(new URL(raw)).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { stripTracking, hostMatcher } from './url.ts';
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { ResolveError, type Media, type Post } from '../types.ts';
|
||||||
|
import { readOpenGraph } from '../browser/capture.ts';
|
||||||
|
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts';
|
||||||
|
import { extractJsonObjectAfterKey } from './scan.ts';
|
||||||
|
import { hostMatcher } from './url.ts';
|
||||||
|
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||||
|
|
||||||
|
const REFERER = 'https://www.instagram.com/';
|
||||||
|
|
||||||
|
type GraphNode = {
|
||||||
|
display_url?: string;
|
||||||
|
video_url?: string;
|
||||||
|
is_video?: boolean;
|
||||||
|
dimensions?: { width?: number; height?: number };
|
||||||
|
accessibility_caption?: string;
|
||||||
|
video_duration?: number;
|
||||||
|
edge_sidecar_to_children?: { edges?: Array<{ node?: GraphNode }> };
|
||||||
|
edge_media_to_caption?: { edges?: Array<{ node?: { text?: string } }> };
|
||||||
|
owner?: { username?: string; full_name?: string; profile_pic_url?: string };
|
||||||
|
taken_at_timestamp?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Scraped = {
|
||||||
|
media: Media[];
|
||||||
|
text?: string;
|
||||||
|
handle?: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
postedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shared Instagram links usually lead with the username. */
|
||||||
|
function handleFromPath(pathRest: string): string | undefined {
|
||||||
|
const first = pathRest.split('/')[0];
|
||||||
|
if (!first || ['p', 'reel', 'reels', 'tv', 'share'].includes(first)) return undefined;
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortcode(pathRest: string): string | undefined {
|
||||||
|
return /(?:^|\/)(?:p|reel|reels|tv)\/([A-Za-z0-9_-]+)/.exec(pathRest)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaFromGraph(node: GraphNode): Media[] {
|
||||||
|
const children = node.edge_sidecar_to_children?.edges;
|
||||||
|
if (children?.length) {
|
||||||
|
return children.flatMap((edge) => (edge.node ? mediaFromGraph(edge.node) : []));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchHeaders = metaHeaders(REFERER);
|
||||||
|
const size = {
|
||||||
|
...(node.dimensions?.width ? { width: node.dimensions.width } : {}),
|
||||||
|
...(node.dimensions?.height ? { height: node.dimensions.height } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node.is_video && node.video_url) {
|
||||||
|
return [{
|
||||||
|
kind: 'video',
|
||||||
|
url: node.video_url,
|
||||||
|
fetchHeaders,
|
||||||
|
...(node.display_url ? { poster: { url: node.display_url, fetchHeaders } } : {}),
|
||||||
|
...(node.video_duration ? { durationSec: Math.round(node.video_duration) } : {}),
|
||||||
|
...size,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.display_url) return [];
|
||||||
|
return [{
|
||||||
|
kind: 'image',
|
||||||
|
url: node.display_url,
|
||||||
|
fetchHeaders,
|
||||||
|
...(node.accessibility_caption ? { alt: node.accessibility_caption } : {}),
|
||||||
|
...size,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromGraph(node: GraphNode): Scraped {
|
||||||
|
return {
|
||||||
|
media: mediaFromGraph(node),
|
||||||
|
...(node.edge_media_to_caption?.edges?.[0]?.node?.text
|
||||||
|
? { text: node.edge_media_to_caption.edges[0].node.text }
|
||||||
|
: {}),
|
||||||
|
...(node.owner?.username ? { handle: node.owner.username } : {}),
|
||||||
|
...(node.owner?.full_name ? { displayName: node.owner.full_name } : {}),
|
||||||
|
...(node.owner?.profile_pic_url ? { avatar: node.owner.profile_pic_url } : {}),
|
||||||
|
...(node.taken_at_timestamp
|
||||||
|
? { postedAt: new Date(node.taken_at_timestamp * 1000).toISOString() }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text of every script on the page, big ones only. */
|
||||||
|
async function scriptTexts(ctx: ResolveContext): Promise<string[]> {
|
||||||
|
return ctx.page
|
||||||
|
.evaluate(() =>
|
||||||
|
[...document.querySelectorAll('script')]
|
||||||
|
.map((el) => el.textContent ?? '')
|
||||||
|
.filter((text) => text.length > 256),
|
||||||
|
)
|
||||||
|
.catch(() => [] as string[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON blobs that are actually addressable as elements. */
|
||||||
|
function parseAll(texts: string[]): unknown[] {
|
||||||
|
return texts.flatMap((text) => {
|
||||||
|
try {
|
||||||
|
return [JSON.parse(text) as unknown];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findShortcodeMedia(texts: string[]): GraphNode | undefined {
|
||||||
|
for (const text of texts) {
|
||||||
|
const node = extractJsonObjectAfterKey<GraphNode>(text, 'shortcode_media');
|
||||||
|
if (node?.display_url ?? node?.video_url ?? node?.edge_sidecar_to_children) return node;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read what the embed actually drew. This is the steadiest signal on the
|
||||||
|
* page — a reel renders a real progressive `<video>` with its poster — but
|
||||||
|
* it only ever shows the first item of a carousel.
|
||||||
|
*/
|
||||||
|
async function fromEmbedDom(ctx: ResolveContext, expectVideo: boolean): Promise<Scraped> {
|
||||||
|
// The embed hydrates its player a beat after the document is ready, so
|
||||||
|
// reading straight away hands back the cover frame instead of the reel.
|
||||||
|
await ctx.page
|
||||||
|
.waitForSelector(expectVideo ? 'video' : 'video, img.EmbeddedMediaImage', { timeout: 8_000 })
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
const found = await ctx.page
|
||||||
|
.evaluate(() => {
|
||||||
|
const videos = [...document.querySelectorAll('video')]
|
||||||
|
.map((el) => ({ src: el.currentSrc || el.src, poster: el.poster }))
|
||||||
|
.filter((v) => v.src && !v.src.startsWith('blob:'));
|
||||||
|
|
||||||
|
const images = [...document.querySelectorAll('img.EmbeddedMediaImage')]
|
||||||
|
.map((el) => (el as HTMLImageElement).src)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const caption = document.querySelector('.Caption');
|
||||||
|
let text: string | undefined;
|
||||||
|
if (caption) {
|
||||||
|
const clone = caption.cloneNode(true) as HTMLElement;
|
||||||
|
for (const drop of clone.querySelectorAll('.CaptionUsername, .CaptionComments')) {
|
||||||
|
drop.remove();
|
||||||
|
}
|
||||||
|
document.body.append(clone);
|
||||||
|
// innerText keeps the line breaks the markup implies; textContent
|
||||||
|
// would run every paragraph together.
|
||||||
|
text = clone.innerText.trim() || undefined;
|
||||||
|
clone.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle =
|
||||||
|
document.querySelector('.UsernameText')?.textContent?.trim() ||
|
||||||
|
document.querySelector('.CaptionUsername')?.textContent?.trim() ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
return { videos, images, text, handle };
|
||||||
|
})
|
||||||
|
.catch(() => ({ videos: [], images: [], text: undefined, handle: undefined }));
|
||||||
|
|
||||||
|
const fetchHeaders = metaHeaders(REFERER);
|
||||||
|
const media: Media[] = found.videos.length
|
||||||
|
? found.videos.map<Media>((video) => ({
|
||||||
|
kind: 'video',
|
||||||
|
url: video.src,
|
||||||
|
fetchHeaders,
|
||||||
|
...(video.poster ? { poster: { url: video.poster, fetchHeaders } } : {}),
|
||||||
|
}))
|
||||||
|
: found.images.map<Media>((url) => ({ kind: 'image', url, fetchHeaders }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
media,
|
||||||
|
...(found.text ? { text: found.text } : {}),
|
||||||
|
...(found.handle ? { handle: found.handle } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whatever structured payload the current page is carrying, if any. */
|
||||||
|
function fromPayloads(texts: string[]): Scraped | undefined {
|
||||||
|
const graph = findShortcodeMedia(texts);
|
||||||
|
if (graph) {
|
||||||
|
const scraped = fromGraph(graph);
|
||||||
|
if (scraped.media.length) return scraped;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloads = parseAll(texts);
|
||||||
|
const meta = findMetaMedia(payloads);
|
||||||
|
if (!meta) return undefined;
|
||||||
|
|
||||||
|
const caption = findCaption(payloads);
|
||||||
|
return {
|
||||||
|
media: mediaFromMetaNode(meta, REFERER),
|
||||||
|
...(caption ? { text: caption } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absolute floor: whatever the page offers a link preview. */
|
||||||
|
async function fromOpenGraph(ctx: ResolveContext): Promise<Scraped> {
|
||||||
|
const og = await readOpenGraph(ctx.page);
|
||||||
|
const fetchHeaders = metaHeaders(REFERER);
|
||||||
|
const media: Media[] = [];
|
||||||
|
|
||||||
|
if (og['og:video']) {
|
||||||
|
media.push({
|
||||||
|
kind: 'video',
|
||||||
|
url: og['og:video'],
|
||||||
|
fetchHeaders,
|
||||||
|
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}),
|
||||||
|
});
|
||||||
|
} else if (og['og:image']) {
|
||||||
|
media.push({ kind: 'image', url: og['og:image'], fetchHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
media,
|
||||||
|
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve(ctx: ResolveContext): Promise<Post> {
|
||||||
|
const { page, originalUrl } = ctx;
|
||||||
|
let code = shortcode(ctx.pathRest);
|
||||||
|
|
||||||
|
// `/share/...` links are redirect stubs. Follow one to find the real post.
|
||||||
|
if (!code || ctx.pathRest.startsWith('share/')) {
|
||||||
|
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
|
||||||
|
code = shortcode(new URL(page.url()).pathname.replace(/^\//, '')) ?? code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new ResolveError('No post code in that Instagram URL', 'instagram', originalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The captioned embed renders logged out and, unlike the post page,
|
||||||
|
// carries the caption with it.
|
||||||
|
await page.goto(`https://www.instagram.com/p/${code}/embed/captioned/`, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The embed renders the media, but the payload behind it carries the
|
||||||
|
// carousel, the dimensions and the alt text, so try that first.
|
||||||
|
const texts = await scriptTexts(ctx);
|
||||||
|
|
||||||
|
let scraped = fromPayloads(texts);
|
||||||
|
|
||||||
|
// The DOM is the reliable floor: whatever is on screen is real, even when
|
||||||
|
// the payload moved again.
|
||||||
|
// A reel is a video whatever else goes wrong, so say so before falling
|
||||||
|
// back to the DOM.
|
||||||
|
const expectsVideo =
|
||||||
|
/(?:^|\/)(?:reel|reels|tv)\//.test(ctx.pathRest) ||
|
||||||
|
scraped?.media.some((item) => item.kind === 'video') === true;
|
||||||
|
|
||||||
|
const dom = await fromEmbedDom(ctx, expectsVideo);
|
||||||
|
if (!scraped?.media.length && dom.media.length) scraped = dom;
|
||||||
|
// The rendered caption beats a payload that only had a truncated one.
|
||||||
|
if (scraped && !scraped.text && dom.text) scraped.text = dom.text;
|
||||||
|
if (scraped && !scraped.handle && dom.handle) scraped.handle = dom.handle;
|
||||||
|
|
||||||
|
if (!scraped?.media.length) {
|
||||||
|
// The embed refuses some posts outright ("the link may be broken").
|
||||||
|
// Try the post itself: its payload first, then its link preview.
|
||||||
|
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
|
||||||
|
scraped = fromPayloads(await scriptTexts(ctx)) ?? scraped;
|
||||||
|
|
||||||
|
if (!scraped?.media.length) {
|
||||||
|
const og = await fromOpenGraph(ctx);
|
||||||
|
if (og.media.length) scraped = { ...og, ...(scraped?.handle ? { handle: scraped.handle } : {}) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scraped?.media.length) {
|
||||||
|
throw new ResolveError(
|
||||||
|
'Instagram would not show that post logged out',
|
||||||
|
'instagram',
|
||||||
|
originalUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = scraped.handle ?? handleFromPath(ctx.pathRest) ?? 'instagram';
|
||||||
|
return {
|
||||||
|
platform: 'instagram',
|
||||||
|
platformLabel: 'Instagram',
|
||||||
|
originalUrl,
|
||||||
|
author: {
|
||||||
|
handle: handle.startsWith('@') ? handle : `@${handle}`,
|
||||||
|
...(scraped.displayName ? { displayName: scraped.displayName } : {}),
|
||||||
|
...(scraped.avatar
|
||||||
|
? { avatar: { url: scraped.avatar, fetchHeaders: metaHeaders(REFERER) } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
...(scraped.text ? { text: scraped.text } : {}),
|
||||||
|
textPosition: 'below',
|
||||||
|
media: scraped.media,
|
||||||
|
...(scraped.postedAt ? { postedAt: scraped.postedAt } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const instagram: PlatformSpec = {
|
||||||
|
id: 'instagram',
|
||||||
|
label: 'Instagram',
|
||||||
|
prefix: 'ig',
|
||||||
|
canonicalHost: 'www.instagram.com',
|
||||||
|
textPosition: 'below',
|
||||||
|
matchesHost: hostMatcher('instagram.com'),
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Media } from '../types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instagram and Threads are the same product underneath and serve the same
|
||||||
|
* media schema, so both adapters normalize through here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Candidate = { url?: string; width?: number; height?: number };
|
||||||
|
|
||||||
|
export type MetaMediaNode = {
|
||||||
|
image_versions2?: { candidates?: Candidate[] };
|
||||||
|
video_versions?: Candidate[];
|
||||||
|
carousel_media?: MetaMediaNode[];
|
||||||
|
accessibility_caption?: string;
|
||||||
|
original_width?: number;
|
||||||
|
original_height?: number;
|
||||||
|
video_duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** These CDNs 403 anything that arrives without a matching referrer. */
|
||||||
|
export function metaHeaders(referer: string): Record<string, string> {
|
||||||
|
return { Referer: referer, Origin: new URL(referer).origin };
|
||||||
|
}
|
||||||
|
|
||||||
|
function largest(candidates: Candidate[] | undefined): Candidate | undefined {
|
||||||
|
return (candidates ?? [])
|
||||||
|
.filter((c) => c.url)
|
||||||
|
.sort((a, b) => (b.width ?? 0) - (a.width ?? 0))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaFromMetaNode(node: MetaMediaNode, referer: string): Media[] {
|
||||||
|
if (node.carousel_media?.length) {
|
||||||
|
return node.carousel_media.flatMap((child) => mediaFromMetaNode(child, referer));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchHeaders = metaHeaders(referer);
|
||||||
|
const poster = largest(node.image_versions2?.candidates);
|
||||||
|
const size = {
|
||||||
|
...(node.original_width ? { width: node.original_width } : {}),
|
||||||
|
...(node.original_height ? { height: node.original_height } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const video = largest(node.video_versions);
|
||||||
|
if (video?.url) {
|
||||||
|
return [{
|
||||||
|
kind: 'video',
|
||||||
|
url: video.url,
|
||||||
|
fetchHeaders,
|
||||||
|
...(poster?.url ? { poster: { url: poster.url, fetchHeaders } } : {}),
|
||||||
|
...(node.video_duration ? { durationSec: Math.round(node.video_duration) } : {}),
|
||||||
|
...size,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (poster?.url) {
|
||||||
|
return [{
|
||||||
|
kind: 'image',
|
||||||
|
url: poster.url,
|
||||||
|
fetchHeaders,
|
||||||
|
...(node.accessibility_caption ? { alt: node.accessibility_caption } : {}),
|
||||||
|
...(poster.width ? { width: poster.width } : size),
|
||||||
|
...(poster.height ? { height: poster.height } : {}),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyArray(value: unknown): boolean {
|
||||||
|
return Array.isArray(value) && value.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carrying the key is not the same as carrying media: these payloads are
|
||||||
|
* full of stub nodes whose `image_versions2.candidates` is an empty array.
|
||||||
|
* Only a node with something in it counts.
|
||||||
|
*/
|
||||||
|
function looksLikeMedia(value: Record<string, unknown>): boolean {
|
||||||
|
if (nonEmptyArray(value['carousel_media'])) return true;
|
||||||
|
if (nonEmptyArray(value['video_versions'])) return true;
|
||||||
|
const images = value['image_versions2'] as { candidates?: unknown } | undefined;
|
||||||
|
return nonEmptyArray(images?.candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Threads buries the post inside a pile of Relay payloads whose shape moves
|
||||||
|
* around. Rather than chase a path, walk the tree for the first node that
|
||||||
|
* carries media and, separately, the first caption.
|
||||||
|
*/
|
||||||
|
export function findMetaMedia(root: unknown): MetaMediaNode | undefined {
|
||||||
|
return walk(root, (record) => (looksLikeMedia(record) ? (record as MetaMediaNode) : undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same walk, looking for `caption.text`. */
|
||||||
|
export function findCaption(root: unknown): string | undefined {
|
||||||
|
return walk(root, (record) => {
|
||||||
|
const caption = record['caption'];
|
||||||
|
if (!caption || typeof caption !== 'object') return undefined;
|
||||||
|
const text = (caption as Record<string, unknown>)['text'];
|
||||||
|
return typeof text === 'string' && text.length > 0 ? text : undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breadth-first so the node nearest the root — the post itself — wins over
|
||||||
|
* anything quoted or recommended below it.
|
||||||
|
*/
|
||||||
|
function walk<T>(root: unknown, visit: (record: Record<string, unknown>) => T | undefined): T | undefined {
|
||||||
|
const seen = new Set<unknown>();
|
||||||
|
const queue: unknown[] = [root];
|
||||||
|
|
||||||
|
for (let i = 0; i < queue.length; i += 1) {
|
||||||
|
const value = queue[i];
|
||||||
|
if (value === null || typeof value !== 'object' || seen.has(value)) continue;
|
||||||
|
seen.add(value);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
queue.push(...value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const hit = visit(record);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
queue.push(...Object.values(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Instagram parks its post payload inside a `requireLazy(...)` bootstrap
|
||||||
|
* call rather than a JSON script tag, so there is no element to read and no
|
||||||
|
* stable path to walk. What is stable is the key itself: find it, then take
|
||||||
|
* the balanced object that follows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the JSON object that follows `"key":` out of a larger source text.
|
||||||
|
*
|
||||||
|
* The payload is often a JSON string inside the outer JSON, so the quotes
|
||||||
|
* arrive escaped. When the plain form misses, the text is unescaped once and
|
||||||
|
* scanned again.
|
||||||
|
*/
|
||||||
|
export function extractJsonObjectAfterKey<T = unknown>(
|
||||||
|
source: string,
|
||||||
|
key: string,
|
||||||
|
): T | undefined {
|
||||||
|
const plain = scanFor<T>(source, key);
|
||||||
|
if (plain !== undefined) return plain;
|
||||||
|
|
||||||
|
if (!source.includes(`\\"${key}\\"`)) return undefined;
|
||||||
|
|
||||||
|
// Unescaping makes the *structure* parseable, but its string leaves are
|
||||||
|
// still carrying the encoding of the level they came from, so they need
|
||||||
|
// one more decode each.
|
||||||
|
const found = scanFor<unknown>(unescapeEmbeddedJson(source), key);
|
||||||
|
return found === undefined ? undefined : (decodeStringLeaves(found) as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reverse one level of JSON string encoding across the structure. */
|
||||||
|
function unescapeEmbeddedJson(source: string): string {
|
||||||
|
return source.replace(/\\(.)/g, (_, char: string) => (char === 'n' ? '\n' : char));
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEEDS_DECODE = /\\["\\/bfnrtu]/;
|
||||||
|
|
||||||
|
function decodeStringLeaves(value: unknown): unknown {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!NEEDS_DECODE.test(value)) return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(`"${value.replace(/"/g, '\\"')}"`) as string;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map(decodeStringLeaves);
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, decodeStringLeaves(v)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanFor<T>(source: string, key: string): T | undefined {
|
||||||
|
const needle = `"${key}":`;
|
||||||
|
let from = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const at = source.indexOf(needle, from);
|
||||||
|
if (at === -1) return undefined;
|
||||||
|
from = at + needle.length;
|
||||||
|
|
||||||
|
const open = source.indexOf('{', from);
|
||||||
|
if (open === -1) return undefined;
|
||||||
|
// Only accept an object that starts right after the key, not one that
|
||||||
|
// happens to appear later because the value was null.
|
||||||
|
if (source.slice(from, open).trim() !== '') continue;
|
||||||
|
|
||||||
|
const end = matchingBrace(source, open);
|
||||||
|
if (end === -1) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(source.slice(open, end + 1)) as T;
|
||||||
|
} catch {
|
||||||
|
// Keep looking: the same key may appear again in a payload that does
|
||||||
|
// parse.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the `}` closing the `{` at `open`, respecting strings and escapes. */
|
||||||
|
function matchingBrace(source: string, open: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
for (let i = open; i < source.length; i += 1) {
|
||||||
|
const char = source[i];
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '\\') {
|
||||||
|
if (inString) escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inString) continue;
|
||||||
|
|
||||||
|
if (char === '{') depth += 1;
|
||||||
|
else if (char === '}') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { ResolveError, type Media, type Post } from '../types.ts';
|
||||||
|
import { readOpenGraph } from '../browser/capture.ts';
|
||||||
|
import { findCaption, findMetaMedia, mediaFromMetaNode, metaHeaders } from './meta-media.ts';
|
||||||
|
import { hostMatcher } from './url.ts';
|
||||||
|
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||||
|
|
||||||
|
const REFERER = 'https://www.threads.com/';
|
||||||
|
|
||||||
|
function handleFromPath(pathRest: string): string | undefined {
|
||||||
|
const match = /^(@[^/]+)/.exec(pathRest);
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Threads ships its Relay payloads in `<script type="application/json">`
|
||||||
|
* tags, several dozen of them, in an order that is not worth relying on. */
|
||||||
|
async function inlinePayloads(ctx: ResolveContext): Promise<unknown[]> {
|
||||||
|
const raw = await ctx.page
|
||||||
|
.evaluate(() =>
|
||||||
|
[...document.querySelectorAll('script[type="application/json"]')]
|
||||||
|
.map((el) => el.textContent ?? '')
|
||||||
|
.filter((text) => text.length > 64),
|
||||||
|
)
|
||||||
|
.catch(() => [] as string[]);
|
||||||
|
|
||||||
|
return raw.flatMap((text) => {
|
||||||
|
try {
|
||||||
|
return [JSON.parse(text) as unknown];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fromOpenGraph(ctx: ResolveContext): Promise<{ media: Media[]; text?: string }> {
|
||||||
|
const og = await readOpenGraph(ctx.page);
|
||||||
|
const fetchHeaders = metaHeaders(REFERER);
|
||||||
|
|
||||||
|
if (og['og:video']) {
|
||||||
|
return {
|
||||||
|
media: [{
|
||||||
|
kind: 'video',
|
||||||
|
url: og['og:video'],
|
||||||
|
fetchHeaders,
|
||||||
|
...(og['og:image'] ? { poster: { url: og['og:image'], fetchHeaders } } : {}),
|
||||||
|
}],
|
||||||
|
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
media: og['og:image'] ? [{ kind: 'image', url: og['og:image'], fetchHeaders }] : [],
|
||||||
|
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve(ctx: ResolveContext): Promise<Post> {
|
||||||
|
const { page, originalUrl, pathRest } = ctx;
|
||||||
|
|
||||||
|
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
const payloads = await inlinePayloads(ctx);
|
||||||
|
const node = findMetaMedia(payloads);
|
||||||
|
|
||||||
|
let media = node ? mediaFromMetaNode(node, REFERER) : [];
|
||||||
|
let text = findCaption(payloads);
|
||||||
|
|
||||||
|
if (media.length === 0 || !text) {
|
||||||
|
const og = await fromOpenGraph(ctx);
|
||||||
|
if (media.length === 0) media = og.media;
|
||||||
|
text ??= og.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Threads post is often only words, so unlike the others an empty media
|
||||||
|
// list is a perfectly good result — but nothing at all is a failure.
|
||||||
|
if (media.length === 0 && !text) {
|
||||||
|
throw new ResolveError('Threads returned nothing for that URL', 'threads', originalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = handleFromPath(pathRest) ?? '@threads';
|
||||||
|
return {
|
||||||
|
platform: 'threads',
|
||||||
|
platformLabel: 'Threads',
|
||||||
|
originalUrl,
|
||||||
|
author: { handle },
|
||||||
|
...(text ? { text } : {}),
|
||||||
|
textPosition: 'above',
|
||||||
|
media,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const threads: PlatformSpec = {
|
||||||
|
id: 'threads',
|
||||||
|
label: 'Threads',
|
||||||
|
prefix: 'threads',
|
||||||
|
canonicalHost: 'www.threads.com',
|
||||||
|
textPosition: 'above',
|
||||||
|
matchesHost: hostMatcher('threads.net', 'threads.com'),
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import type { Page } from 'playwright';
|
||||||
|
import { ChallengeError, ResolveError, type Media, type Post } from '../types.ts';
|
||||||
|
import { cookieHeaderFor } from '../browser/pool.ts';
|
||||||
|
import { CHALLENGE_SELECTORS, park } from '../challenge/registry.ts';
|
||||||
|
import { config } from '../config.ts';
|
||||||
|
import { readInlineJson, readOpenGraph } from '../browser/capture.ts';
|
||||||
|
import { hostMatcher } from './url.ts';
|
||||||
|
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||||
|
|
||||||
|
const REFERER = 'https://www.tiktok.com/';
|
||||||
|
|
||||||
|
type UrlList = { UrlList?: string[] };
|
||||||
|
|
||||||
|
type ItemStruct = {
|
||||||
|
id?: string;
|
||||||
|
desc?: string;
|
||||||
|
createTime?: number | string;
|
||||||
|
author?: { uniqueId?: string; nickname?: string; avatarMedium?: string; avatarLarger?: string };
|
||||||
|
video?: {
|
||||||
|
playAddr?: string;
|
||||||
|
downloadAddr?: string;
|
||||||
|
cover?: string;
|
||||||
|
duration?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
bitrateInfo?: Array<{ Bitrate?: number; PlayAddr?: UrlList }>;
|
||||||
|
};
|
||||||
|
imagePost?: {
|
||||||
|
images?: Array<{ imageURL?: UrlList; imageWidth?: number; imageHeight?: number }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Rehydration = {
|
||||||
|
__DEFAULT_SCOPE__?: Record<string, { itemInfo?: { itemStruct?: ItemStruct } } | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The post lives under `webapp.video-detail` for a video and a different key
|
||||||
|
* for a photo post, so take whichever scope actually carries one rather than
|
||||||
|
* naming them.
|
||||||
|
*/
|
||||||
|
function findItemStruct(data: Rehydration | undefined): ItemStruct | undefined {
|
||||||
|
for (const scope of Object.values(data?.__DEFAULT_SCOPE__ ?? {})) {
|
||||||
|
const item = scope?.itemInfo?.itemStruct;
|
||||||
|
if (item) return item;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TikTok's CDN checks the referrer and, for some regions, the session
|
||||||
|
* cookies the page was served with. The proxy replays both.
|
||||||
|
*/
|
||||||
|
async function tiktokHeaders(url: string): Promise<Record<string, string>> {
|
||||||
|
const cookie = await cookieHeaderFor(url);
|
||||||
|
return {
|
||||||
|
Referer: REFERER,
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared TikTok links lead with the author, e.g. `@user/video/123`. */
|
||||||
|
function handleFrom(url: string): string | undefined {
|
||||||
|
const match = /tiktok\.com\/(@[\w.-]+)\//.exec(url);
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestPlayAddr(video: NonNullable<ItemStruct['video']>): string | undefined {
|
||||||
|
const ranked = (video.bitrateInfo ?? [])
|
||||||
|
.filter((entry) => entry.PlayAddr?.UrlList?.length)
|
||||||
|
.sort((a, b) => (b.Bitrate ?? 0) - (a.Bitrate ?? 0))[0];
|
||||||
|
return ranked?.PlayAddr?.UrlList?.[0] ?? video.playAddr ?? video.downloadAddr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mediaFromItem(item: ItemStruct): Promise<Media[]> {
|
||||||
|
// A TikTok photo post carries no video at all.
|
||||||
|
const photos = item.imagePost?.images;
|
||||||
|
if (photos?.length) {
|
||||||
|
const results: Media[] = [];
|
||||||
|
for (const photo of photos) {
|
||||||
|
const url = photo.imageURL?.UrlList?.[0];
|
||||||
|
if (!url) continue;
|
||||||
|
results.push({
|
||||||
|
kind: 'image',
|
||||||
|
url,
|
||||||
|
fetchHeaders: await tiktokHeaders(url),
|
||||||
|
...(photo.imageWidth ? { width: photo.imageWidth } : {}),
|
||||||
|
...(photo.imageHeight ? { height: photo.imageHeight } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = item.video;
|
||||||
|
const src = video ? bestPlayAddr(video) : undefined;
|
||||||
|
if (!video || !src) return [];
|
||||||
|
|
||||||
|
return [{
|
||||||
|
kind: 'video',
|
||||||
|
url: src,
|
||||||
|
fetchHeaders: await tiktokHeaders(src),
|
||||||
|
...(video.cover
|
||||||
|
? { poster: { url: video.cover, fetchHeaders: await tiktokHeaders(video.cover) } }
|
||||||
|
: {}),
|
||||||
|
...(video.duration ? { durationSec: video.duration } : {}),
|
||||||
|
...(video.width ? { width: video.width } : {}),
|
||||||
|
...(video.height ? { height: video.height } : {}),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TikTok sometimes answers with a slider puzzle instead of the post.
|
||||||
|
*
|
||||||
|
* It renders a moment after the document is ready, so this waits rather than
|
||||||
|
* looking once — checking too early reports a broken adapter when the real
|
||||||
|
* answer is "there is a puzzle in the way".
|
||||||
|
*/
|
||||||
|
async function isChallenged(page: Page): Promise<boolean> {
|
||||||
|
const appeared = await page
|
||||||
|
.waitForSelector(CHALLENGE_SELECTORS, { state: 'visible', timeout: 5_000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (appeared) return true;
|
||||||
|
|
||||||
|
return page
|
||||||
|
.evaluate(() => /drag the slider|verify to continue/i.test(document.body?.innerText ?? ''))
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve(ctx: ResolveContext): Promise<Post> {
|
||||||
|
const { page, originalUrl, detach } = ctx;
|
||||||
|
|
||||||
|
// Short links (`vm.`/`vt.`) are redirect stubs; loading one lands on the
|
||||||
|
// real post, and `page.url()` is then the canonical address.
|
||||||
|
await page.goto(originalUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
const landed = page.url();
|
||||||
|
|
||||||
|
const data = await readInlineJson<Rehydration>(
|
||||||
|
page,
|
||||||
|
'script#__UNIVERSAL_DATA_FOR_REHYDRATION__',
|
||||||
|
config.navigationTimeoutMs,
|
||||||
|
);
|
||||||
|
const item = findItemStruct(data);
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
const media = await mediaFromItem(item);
|
||||||
|
if (media.length > 0) {
|
||||||
|
const handle = item.author?.uniqueId ?? 'tiktok';
|
||||||
|
const avatar = item.author?.avatarLarger ?? item.author?.avatarMedium;
|
||||||
|
const created = Number(item.createTime);
|
||||||
|
return {
|
||||||
|
platform: 'tiktok',
|
||||||
|
platformLabel: 'TikTok',
|
||||||
|
originalUrl: landed.startsWith('https://www.tiktok.com/') ? landed : originalUrl,
|
||||||
|
author: {
|
||||||
|
handle: `@${handle}`,
|
||||||
|
...(item.author?.nickname ? { displayName: item.author.nickname } : {}),
|
||||||
|
...(avatar ? { avatar: { url: avatar, fetchHeaders: await tiktokHeaders(avatar) } } : {}),
|
||||||
|
},
|
||||||
|
...(item.desc ? { text: item.desc } : {}),
|
||||||
|
textPosition: 'below',
|
||||||
|
media,
|
||||||
|
...(Number.isFinite(created) && created > 0
|
||||||
|
? { postedAt: new Date(created * 1000).toISOString() }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the link preview, which at least gets a poster on screen.
|
||||||
|
if (await isChallenged(page)) {
|
||||||
|
// Keep the page open and hand the puzzle to whoever is reading. Solving
|
||||||
|
// it deposits the cookie in the shared browser context, so the retry
|
||||||
|
// afterwards is an ordinary request.
|
||||||
|
detach();
|
||||||
|
const id = await park({
|
||||||
|
page,
|
||||||
|
platform: 'tiktok',
|
||||||
|
platformLabel: 'TikTok',
|
||||||
|
originalUrl,
|
||||||
|
});
|
||||||
|
throw new ChallengeError(id, 'tiktok', originalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const og = await readOpenGraph(page);
|
||||||
|
if (og['og:video'] ?? og['og:image']) {
|
||||||
|
const url = og['og:video'] ?? (og['og:image'] as string);
|
||||||
|
return {
|
||||||
|
platform: 'tiktok',
|
||||||
|
platformLabel: 'TikTok',
|
||||||
|
originalUrl,
|
||||||
|
author: { handle: handleFrom(landed) ?? handleFrom(originalUrl) ?? '@tiktok' },
|
||||||
|
...(og['og:description'] ? { text: og['og:description'] } : {}),
|
||||||
|
textPosition: 'below',
|
||||||
|
media: [{
|
||||||
|
kind: og['og:video'] ? 'video' : 'image',
|
||||||
|
url,
|
||||||
|
fetchHeaders: await tiktokHeaders(url),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ResolveError('TikTok would not return that post', 'tiktok', originalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tiktok: PlatformSpec = {
|
||||||
|
id: 'tiktok',
|
||||||
|
label: 'TikTok',
|
||||||
|
prefix: 'tiktok',
|
||||||
|
canonicalHost: 'www.tiktok.com',
|
||||||
|
textPosition: 'below',
|
||||||
|
matchesHost: hostMatcher('tiktok.com'),
|
||||||
|
// A single opaque segment is a `vm.`/`vt.` share code, not a post path.
|
||||||
|
buildOriginalUrl: (rest, search) =>
|
||||||
|
/^[A-Za-z0-9]+\/?$/.test(rest)
|
||||||
|
? `https://vm.tiktok.com/${rest.replace(/\/$/, '')}/`
|
||||||
|
: `https://www.tiktok.com/${rest}${search}`,
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Page } from 'playwright';
|
||||||
|
import type { PlatformId, Post } from '../types.ts';
|
||||||
|
|
||||||
|
export type PlatformSpec = {
|
||||||
|
id: PlatformId;
|
||||||
|
/** Human label for the badge. */
|
||||||
|
label: string;
|
||||||
|
/** The path segment antisocial serves this platform under. */
|
||||||
|
prefix: string;
|
||||||
|
/** Host used when rebuilding the original URL from our path. */
|
||||||
|
canonicalHost: string;
|
||||||
|
textPosition: 'above' | 'below';
|
||||||
|
/** Matches the host of an original URL, so a pasted link can be routed. */
|
||||||
|
matchesHost: (host: string) => boolean;
|
||||||
|
/**
|
||||||
|
* Rebuild the canonical original URL from the path remainder and query
|
||||||
|
* string we were handed. Overridden where a straight host swap is wrong
|
||||||
|
* (TikTok short links, for one).
|
||||||
|
*/
|
||||||
|
buildOriginalUrl?: (pathRest: string, search: string) => string;
|
||||||
|
resolve: Adapter;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveContext = {
|
||||||
|
page: Page;
|
||||||
|
/** Keep the page open past this resolve; the caller closes it instead.
|
||||||
|
* Used to hand a challenge page to the viewer. */
|
||||||
|
detach: () => void;
|
||||||
|
spec: PlatformSpec;
|
||||||
|
/** Canonical original URL, tracking parameters already stripped. */
|
||||||
|
originalUrl: string;
|
||||||
|
/** Path remainder after the platform prefix, no leading slash. */
|
||||||
|
pathRest: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Adapter = (ctx: ResolveContext) => Promise<Post>;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Query parameters that only exist to record where a link travelled. They
|
||||||
|
* are dropped so the copy button hands back something clean and the cache
|
||||||
|
* does not store one post under a dozen keys.
|
||||||
|
*/
|
||||||
|
const TRACKING_PARAMS = new Set([
|
||||||
|
'igsh', 'igshid', 'img_index', 'fbclid', 'gclid', 'mibextid',
|
||||||
|
'ref_src', 'ref_url', 'ref', 'source', 'checksum', 'social_sharing',
|
||||||
|
's', 't', 'si', 'xmt', '_r', '_t', '_d',
|
||||||
|
'is_from_webapp', 'sender_device', 'sender_web_id', 'web_id',
|
||||||
|
'share_app_id', 'share_link_id', 'share_item_id', 'tt_from',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function stripTracking(url: URL): URL {
|
||||||
|
const cleaned = new URL(url.href);
|
||||||
|
for (const key of [...cleaned.searchParams.keys()]) {
|
||||||
|
if (TRACKING_PARAMS.has(key) || key.startsWith('utm_')) {
|
||||||
|
cleaned.searchParams.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleaned.hash = '';
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact-host or subdomain match, e.g. `tiktok.com` and `vm.tiktok.com`. */
|
||||||
|
export function hostMatcher(...domains: string[]): (host: string) => boolean {
|
||||||
|
return (host) => domains.some((d) => host === d || host.endsWith(`.${d}`));
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { config } from '../config.ts';
|
||||||
|
import { ResolveError, type Media, type Post } from '../types.ts';
|
||||||
|
import { captureJson } from '../browser/capture.ts';
|
||||||
|
import { hostMatcher } from './url.ts';
|
||||||
|
import type { PlatformSpec, ResolveContext } from './types.ts';
|
||||||
|
|
||||||
|
type Size = { w?: number; h?: number };
|
||||||
|
|
||||||
|
type VideoVariant = { bitrate?: number; content_type?: string; url?: string };
|
||||||
|
|
||||||
|
type MediaDetail = {
|
||||||
|
type?: 'photo' | 'video' | 'animated_gif';
|
||||||
|
media_url_https?: string;
|
||||||
|
ext_alt_text?: string;
|
||||||
|
original_info?: Size;
|
||||||
|
video_info?: { duration_millis?: number; variants?: VideoVariant[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
type TweetResult = {
|
||||||
|
text?: string;
|
||||||
|
created_at?: string;
|
||||||
|
user?: { screen_name?: string; name?: string; profile_image_url_https?: string };
|
||||||
|
mediaDetails?: MediaDetail[];
|
||||||
|
quoted_tweet?: TweetResult;
|
||||||
|
tombstone?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every X post URL ends in `/status/<id>`, whatever precedes it. */
|
||||||
|
function tweetId(pathRest: string): string | undefined {
|
||||||
|
return /\/status(?:es)?\/(\d+)/.exec(`/${pathRest}`)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The syndication endpoint wants a token derived from the id. This is the
|
||||||
|
* same arithmetic the embed script does before calling it.
|
||||||
|
*/
|
||||||
|
function syndicationToken(id: string): string {
|
||||||
|
return ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestVariant(variants: VideoVariant[] | undefined): VideoVariant | undefined {
|
||||||
|
return (variants ?? [])
|
||||||
|
.filter((v) => v.content_type === 'video/mp4' && v.url)
|
||||||
|
.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaFromDetails(details: MediaDetail[] | undefined): Media[] {
|
||||||
|
return (details ?? []).flatMap<Media>((detail) => {
|
||||||
|
const size = {
|
||||||
|
...(detail.original_info?.w ? { width: detail.original_info.w } : {}),
|
||||||
|
...(detail.original_info?.h ? { height: detail.original_info.h } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (detail.type === 'photo') {
|
||||||
|
if (!detail.media_url_https) return [];
|
||||||
|
return [{
|
||||||
|
kind: 'image',
|
||||||
|
// `?name=orig` asks twimg for the untouched upload.
|
||||||
|
url: `${detail.media_url_https}?name=orig`,
|
||||||
|
...(detail.ext_alt_text ? { alt: detail.ext_alt_text } : {}),
|
||||||
|
...size,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant = bestVariant(detail.video_info?.variants);
|
||||||
|
if (!variant?.url) return [];
|
||||||
|
const duration = detail.video_info?.duration_millis;
|
||||||
|
return [{
|
||||||
|
kind: 'video',
|
||||||
|
url: variant.url,
|
||||||
|
...(detail.media_url_https ? { poster: { url: detail.media_url_https } } : {}),
|
||||||
|
...(duration ? { durationSec: Math.round(duration / 1000) } : {}),
|
||||||
|
...size,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPost(result: TweetResult, originalUrl: string): Post {
|
||||||
|
const handle = result.user?.screen_name ?? 'unknown';
|
||||||
|
const avatar = result.user?.profile_image_url_https;
|
||||||
|
return {
|
||||||
|
platform: 'x',
|
||||||
|
platformLabel: 'X',
|
||||||
|
originalUrl,
|
||||||
|
author: {
|
||||||
|
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',
|
||||||
|
// A quote post usually carries no media of its own — the picture being
|
||||||
|
// talked about belongs to the post it quotes, and that is the thing
|
||||||
|
// worth showing.
|
||||||
|
media: mediaFromDetails(
|
||||||
|
result.mediaDetails?.length ? result.mediaDetails : result.quoted_tweet?.mediaDetails,
|
||||||
|
),
|
||||||
|
...(result.created_at ? { postedAt: result.created_at } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolve({ page, originalUrl, pathRest }: ResolveContext): Promise<Post> {
|
||||||
|
const id = tweetId(pathRest);
|
||||||
|
if (!id) throw new ResolveError('No post id in that X URL', 'x', originalUrl);
|
||||||
|
|
||||||
|
// The official embed renders logged out, and calls the syndication
|
||||||
|
// endpoint on our behalf. Catching that response gives structured media
|
||||||
|
// with alt text, rather than whatever the embed chose to draw.
|
||||||
|
const captured = captureJson<TweetResult>(page, (url) => url.includes('/tweet-result'));
|
||||||
|
|
||||||
|
const embedUrl = new URL('https://platform.twitter.com/embed/Tweet.html');
|
||||||
|
embedUrl.searchParams.set('id', id);
|
||||||
|
embedUrl.searchParams.set('lang', 'en');
|
||||||
|
await page.goto(embedUrl.href, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
|
||||||
|
|
||||||
|
let result = await captured.wait(config.navigationTimeoutMs);
|
||||||
|
|
||||||
|
if (!result?.user) {
|
||||||
|
// Ask for it ourselves if the embed did not, or was slow.
|
||||||
|
const api = new URL('https://cdn.syndication.twimg.com/tweet-result');
|
||||||
|
api.searchParams.set('id', id);
|
||||||
|
api.searchParams.set('lang', 'en');
|
||||||
|
api.searchParams.set('token', syndicationToken(id));
|
||||||
|
result = await page.request
|
||||||
|
.get(api.href, { headers: { Referer: 'https://platform.twitter.com/' } })
|
||||||
|
.then((r) => (r.ok() ? (r.json() as Promise<TweetResult>) : undefined))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result?.user || result.tombstone) {
|
||||||
|
throw new ResolveError(
|
||||||
|
'X would not return that post — it may be deleted, protected, or age restricted',
|
||||||
|
'x',
|
||||||
|
originalUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toPost(result, originalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const x: PlatformSpec = {
|
||||||
|
id: 'x',
|
||||||
|
label: 'X',
|
||||||
|
prefix: 'x',
|
||||||
|
canonicalHost: 'x.com',
|
||||||
|
textPosition: 'above',
|
||||||
|
matchesHost: hostMatcher('x.com', 'twitter.com'),
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Challenge } from '../challenge/registry.ts';
|
||||||
|
import { html } from './html.ts';
|
||||||
|
import { badge, layout } from './layout.ts';
|
||||||
|
|
||||||
|
type Box = { x: number; y: number; width: number; height: number };
|
||||||
|
|
||||||
|
export function renderChallenge(challenge: Challenge, box: Box | undefined): string {
|
||||||
|
const body = html`<article class="post post--challenge" data-challenge="${challenge.id}">
|
||||||
|
<header class="post__head">
|
||||||
|
${badge(challenge.platform, challenge.platformLabel)}
|
||||||
|
<div class="who"><span class="who__handle">wants you to prove you are a person</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="challenge__lede">
|
||||||
|
Solve it here and the post loads. Drag on the picture exactly as you would on
|
||||||
|
${challenge.platformLabel}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="challenge__stage"
|
||||||
|
id="stage"
|
||||||
|
data-box="${box ? `${box.x},${box.y},${box.width},${box.height}` : ''}"
|
||||||
|
>
|
||||||
|
<img id="frame" alt="The verification puzzle" draggable="false">
|
||||||
|
<p class="challenge__status" id="status">Loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="post__foot">
|
||||||
|
<div class="original">
|
||||||
|
<button type="button" class="copy" id="give-up">Give up and show me the link</button>
|
||||||
|
<a class="original__open" href="${challenge.originalUrl}" rel="noopener noreferrer nofollow" target="_blank">
|
||||||
|
Open on ${challenge.platformLabel}
|
||||||
|
</a>
|
||||||
|
<p class="original__url"><code>${challenge.originalUrl}</code></p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>`;
|
||||||
|
|
||||||
|
return layout(`Verify — ${challenge.platformLabel}`, body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { html } from './html.ts';
|
||||||
|
import { badge, layout, originalUrlBlock } from './layout.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resolve can always fail — someone else's markup changed, the post was
|
||||||
|
* deleted, a platform tightened a screw. Failing still has to leave the
|
||||||
|
* link one tap away, so this is a card rather than a status page.
|
||||||
|
*/
|
||||||
|
export function renderError(options: {
|
||||||
|
platform?: string;
|
||||||
|
platformLabel?: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
heading: string;
|
||||||
|
detail?: string;
|
||||||
|
}): string {
|
||||||
|
const { platform, platformLabel = 'the original', originalUrl, heading, detail } = options;
|
||||||
|
|
||||||
|
const body = html`<article class="post post--error">
|
||||||
|
<header class="post__head">
|
||||||
|
${platform ? badge(platform, platformLabel) : ''}
|
||||||
|
</header>
|
||||||
|
<h1 class="error__heading">${heading}</h1>
|
||||||
|
${detail ? html`<p class="error__detail">${detail}</p>` : ''}
|
||||||
|
${originalUrl
|
||||||
|
? html`<footer class="post__foot">${originalUrlBlock(originalUrl, platformLabel)}</footer>`
|
||||||
|
: ''}
|
||||||
|
</article>`;
|
||||||
|
|
||||||
|
return layout(heading, body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/** A string that is already safe to drop into the document. */
|
||||||
|
export class Raw {
|
||||||
|
readonly value: string;
|
||||||
|
constructor(value: string) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function raw(value: string): Raw {
|
||||||
|
return new Raw(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ESCAPES: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === false) return '';
|
||||||
|
if (value instanceof Raw) return value.value;
|
||||||
|
if (Array.isArray(value)) return value.map(render).join('');
|
||||||
|
return escapeHtml(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tagged template that escapes every interpolation unless it is already
|
||||||
|
* `Raw`. Post text comes from strangers, so nothing reaches the document
|
||||||
|
* without passing through here.
|
||||||
|
*/
|
||||||
|
export function html(strings: TemplateStringsArray, ...values: unknown[]): Raw {
|
||||||
|
let out = strings[0] ?? '';
|
||||||
|
for (let i = 0; i < values.length; i += 1) {
|
||||||
|
out += render(values[i]) + (strings[i + 1] ?? '');
|
||||||
|
}
|
||||||
|
return new Raw(out);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { config } from '../config.ts';
|
||||||
|
import type { PlatformSpec } from '../platforms/types.ts';
|
||||||
|
import { html } from './html.ts';
|
||||||
|
import { badge, layout } from './layout.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is no useful landing page for a redirect target, so this is the
|
||||||
|
* reference card instead: which prefixes exist, and the rewrite rules to
|
||||||
|
* paste into StopTheMadness.
|
||||||
|
*/
|
||||||
|
export function renderIndex(platforms: readonly PlatformSpec[]): string {
|
||||||
|
const origin = config.publicOrigin;
|
||||||
|
|
||||||
|
const body = html`<article class="post post--index">
|
||||||
|
<h1 class="index__title">antisocial</h1>
|
||||||
|
<p class="index__lede">
|
||||||
|
Point StopTheMadness at these and shared links open here instead of in an app.
|
||||||
|
</p>
|
||||||
|
<table class="index__table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Platform</th><th>Matches</th><th>Rewrites to</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${platforms.map(
|
||||||
|
(p) => html`<tr>
|
||||||
|
<td>${badge(p.id, p.label)}</td>
|
||||||
|
<td><code>${p.canonicalHost}</code></td>
|
||||||
|
<td><code>${origin}/${p.prefix}/…</code></td>
|
||||||
|
</tr>`,
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>`;
|
||||||
|
|
||||||
|
return layout('antisocial', body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { html, type Raw } from './html.ts';
|
||||||
|
|
||||||
|
export function layout(title: string, body: Raw): string {
|
||||||
|
return `<!doctype html>
|
||||||
|
${html`<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>${title}</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>${body}</main>
|
||||||
|
<script src="/static/app.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicative brand accents. The written label is what actually identifies
|
||||||
|
* the platform; the colour is only there to make it recognisable at a
|
||||||
|
* glance.
|
||||||
|
*/
|
||||||
|
const ACCENT: Record<string, { color: string; monogram: string }> = {
|
||||||
|
x: { color: '#0f1419', monogram: '✕' },
|
||||||
|
threads: { color: '#3b3b3b', monogram: '@' },
|
||||||
|
instagram: { color: '#c13584', monogram: 'IG' },
|
||||||
|
tiktok: { color: '#fe2c55', monogram: '♪' },
|
||||||
|
bluesky: { color: '#0085ff', monogram: 'B' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function badge(platform: string, label: string): Raw {
|
||||||
|
const accent = ACCENT[platform] ?? { color: '#666', monogram: '?' };
|
||||||
|
return html`<span class="badge" style="--accent: ${accent.color}">
|
||||||
|
<span class="badge__mark" aria-hidden="true">${accent.monogram}</span>
|
||||||
|
<span class="badge__label">${label}</span>
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The copy control, plus the URL itself so it is always selectable even if
|
||||||
|
* the clipboard API is unavailable. */
|
||||||
|
export function originalUrlBlock(originalUrl: string, platformLabel: string): Raw {
|
||||||
|
return html`<div class="original">
|
||||||
|
<button type="button" class="copy" data-url="${originalUrl}">
|
||||||
|
<span class="copy__idle">Copy original link</span>
|
||||||
|
<span class="copy__done" hidden>Copied</span>
|
||||||
|
</button>
|
||||||
|
<a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank">Open on ${platformLabel}</a>
|
||||||
|
<p class="original__url"><code>${originalUrl}</code></p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { proxyUrlFor } from '../media/registry.ts';
|
||||||
|
import type { Media, Post } from '../types.ts';
|
||||||
|
import { html, type Raw } from './html.ts';
|
||||||
|
import { badge, layout, originalUrlBlock } from './layout.ts';
|
||||||
|
import { linkify } from './text.ts';
|
||||||
|
|
||||||
|
function aspect(item: Media): string {
|
||||||
|
return item.width && item.height ? `aspect-ratio: ${item.width} / ${item.height};` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItem(item: Media, index: number): Raw {
|
||||||
|
const src = proxyUrlFor(item);
|
||||||
|
|
||||||
|
if (item.kind === 'video') {
|
||||||
|
const poster = item.poster ? proxyUrlFor(item.poster) : undefined;
|
||||||
|
return html`<figure class="item item--video" data-index="${index}" style="${aspect(item)}">
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
${poster ? html`poster="${poster}"` : ''}
|
||||||
|
><source src="${src}"></video>
|
||||||
|
${item.hls
|
||||||
|
? html`<figcaption class="item__note">Streaming format — plays natively in Safari.</figcaption>`
|
||||||
|
: ''}
|
||||||
|
</figure>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<figure class="item item--image" data-index="${index}" style="${aspect(item)}">
|
||||||
|
<img
|
||||||
|
src="${src}"
|
||||||
|
alt="${item.alt ?? ''}"
|
||||||
|
loading="${index === 0 ? 'eager' : 'lazy'}"
|
||||||
|
decoding="async"
|
||||||
|
>
|
||||||
|
</figure>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMedia(media: Media[]): Raw {
|
||||||
|
if (media.length === 0) return html``;
|
||||||
|
|
||||||
|
const many = media.length > 1;
|
||||||
|
return html`<section class="media" data-count="${media.length}">
|
||||||
|
${many
|
||||||
|
? html`<div class="media__bar">
|
||||||
|
<span class="media__count"><span class="media__at">1</span> / ${media.length}</span>
|
||||||
|
<div class="media__views" role="group" aria-label="Layout">
|
||||||
|
<button type="button" class="media__view" data-view="swipe" aria-pressed="true">Swipe</button>
|
||||||
|
<button type="button" class="media__view" data-view="grid" aria-pressed="false">Grid</button>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
|
<div class="media__rail">${media.map((item, index) => renderItem(item, index))}</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderText(post: Post): Raw {
|
||||||
|
if (!post.text) return html``;
|
||||||
|
return html`<div class="text">${linkify(post.text, post.platform)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWhen(post: Post): Raw {
|
||||||
|
if (!post.postedAt) return html``;
|
||||||
|
const date = new Date(post.postedAt);
|
||||||
|
if (Number.isNaN(date.getTime())) return html``;
|
||||||
|
return html`<time datetime="${date.toISOString()}">${date.toLocaleString('en-CA', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})}</time>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPost(post: Post): string {
|
||||||
|
const avatar = post.author.avatar ? proxyUrlFor(post.author.avatar) : undefined;
|
||||||
|
const above = post.textPosition === 'above';
|
||||||
|
|
||||||
|
const body = html`<article class="post" data-platform="${post.platform}">
|
||||||
|
<header class="post__head">
|
||||||
|
${badge(post.platform, post.platformLabel)}
|
||||||
|
<div class="who">
|
||||||
|
${avatar ? html`<img class="who__avatar" src="${avatar}" alt="" loading="lazy">` : ''}
|
||||||
|
<div class="who__names">
|
||||||
|
${post.author.displayName ? html`<span class="who__name">${post.author.displayName}</span>` : ''}
|
||||||
|
<span class="who__handle">${post.author.handle}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
${above ? renderText(post) : ''}
|
||||||
|
${renderMedia(post.media)}
|
||||||
|
${above ? '' : renderText(post)}
|
||||||
|
|
||||||
|
<footer class="post__foot">
|
||||||
|
${renderWhen(post)}
|
||||||
|
${originalUrlBlock(post.originalUrl, post.platformLabel)}
|
||||||
|
</footer>
|
||||||
|
</article>`;
|
||||||
|
|
||||||
|
const who = post.author.displayName ?? post.author.handle;
|
||||||
|
return layout(`${who} on ${post.platformLabel}`, body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { escapeHtml, raw, type Raw } from './html.ts';
|
||||||
|
import type { PlatformId } from '../types.ts';
|
||||||
|
|
||||||
|
/** Where an @mention or #hashtag points on each platform. */
|
||||||
|
const PROFILE_BASE: Record<PlatformId, string> = {
|
||||||
|
x: 'https://x.com/',
|
||||||
|
threads: 'https://www.threads.com/@',
|
||||||
|
instagram: 'https://www.instagram.com/',
|
||||||
|
tiktok: 'https://www.tiktok.com/@',
|
||||||
|
bluesky: 'https://bsky.app/profile/',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAG_BASE: Partial<Record<PlatformId, string>> = {
|
||||||
|
x: 'https://x.com/hashtag/',
|
||||||
|
instagram: 'https://www.instagram.com/explore/tags/',
|
||||||
|
tiktok: 'https://www.tiktok.com/tag/',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN = /(https?:\/\/[^\s<]+)|(^|\s)(@[\w.-]+)|(^|\s)(#[\w]+)/g;
|
||||||
|
|
||||||
|
function link(href: string, label: string): string {
|
||||||
|
return `<a href="${escapeHtml(href)}" rel="noopener noreferrer nofollow" target="_blank">${escapeHtml(label)}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn post text into markup: escape everything, then make links, mentions
|
||||||
|
* and hashtags clickable back to the platform they came from.
|
||||||
|
*/
|
||||||
|
export function linkify(text: string, platform: PlatformId): Raw {
|
||||||
|
let out = '';
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (const match of text.matchAll(TOKEN)) {
|
||||||
|
const index = match.index;
|
||||||
|
out += escapeHtml(text.slice(cursor, index));
|
||||||
|
|
||||||
|
const [whole, url, mentionLead, mention, tagLead, tag] = match;
|
||||||
|
if (url) {
|
||||||
|
out += link(url, url.replace(/^https?:\/\/(www\.)?/, ''));
|
||||||
|
} else if (mention) {
|
||||||
|
out += escapeHtml(mentionLead ?? '') + link(PROFILE_BASE[platform] + mention.slice(1), mention);
|
||||||
|
} else if (tag) {
|
||||||
|
const base = TAG_BASE[platform];
|
||||||
|
out += escapeHtml(tagLead ?? '') + (base ? link(base + tag.slice(1), tag) : escapeHtml(tag));
|
||||||
|
} else {
|
||||||
|
out += escapeHtml(whole);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = index + whole.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
out += escapeHtml(text.slice(cursor));
|
||||||
|
return raw(out.replace(/\n/g, '<br>'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { LRUCache } from 'lru-cache';
|
||||||
|
import { config } from './config.ts';
|
||||||
|
import { withPage } from './browser/pool.ts';
|
||||||
|
import { withTimeout } from './browser/queue.ts';
|
||||||
|
import { ResolveError, type Post } from './types.ts';
|
||||||
|
import type { PlatformSpec } from './platforms/types.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved posts, keyed by canonical URL. Reloading, hitting back, or
|
||||||
|
* opening the same link twice must not drive Chromium again.
|
||||||
|
*/
|
||||||
|
const cache = new LRUCache<string, Post>({
|
||||||
|
max: config.cacheMax,
|
||||||
|
ttl: config.cacheTtlMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function cacheSize(): number {
|
||||||
|
return cache.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePost(
|
||||||
|
spec: PlatformSpec,
|
||||||
|
originalUrl: string,
|
||||||
|
pathRest: string,
|
||||||
|
): Promise<Post> {
|
||||||
|
const hit = cache.get(originalUrl);
|
||||||
|
if (hit) return hit;
|
||||||
|
|
||||||
|
const post = await withTimeout(
|
||||||
|
withPage(({ page, detach }) => spec.resolve({ page, detach, spec, originalUrl, pathRest })),
|
||||||
|
config.resolveTimeoutMs,
|
||||||
|
`${spec.label} resolve`,
|
||||||
|
).catch((error: unknown) => {
|
||||||
|
if (error instanceof ResolveError) throw error;
|
||||||
|
throw new ResolveError(
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
spec.id,
|
||||||
|
originalUrl,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.set(originalUrl, post);
|
||||||
|
return post;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import {
|
||||||
|
CHALLENGE_SELECTORS,
|
||||||
|
get,
|
||||||
|
isSolved,
|
||||||
|
release,
|
||||||
|
type Challenge,
|
||||||
|
} from '../challenge/registry.ts';
|
||||||
|
import { renderChallenge } from '../render/challenge.ts';
|
||||||
|
import { renderError } from '../render/error.ts';
|
||||||
|
|
||||||
|
type Box = { x: number; y: number; width: number; height: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The puzzle is rendered in a real browser here, not in the viewer's. So
|
||||||
|
* frames go out as screenshots and pointer events come back in and get
|
||||||
|
* replayed onto the page. Solving it leaves the cookie in the shared browser
|
||||||
|
* context, which is the whole point: the retry then behaves like any other
|
||||||
|
* request.
|
||||||
|
*/
|
||||||
|
async function frameBox(challenge: Challenge): Promise<Box | undefined> {
|
||||||
|
// Prefer the puzzle itself over the whole viewport — on a phone the
|
||||||
|
// difference is between a usable slider and a postage stamp.
|
||||||
|
const box = await challenge.page
|
||||||
|
.locator(CHALLENGE_SELECTORS)
|
||||||
|
.first()
|
||||||
|
.boundingBox({ timeout: 2_000 })
|
||||||
|
.catch(() => null);
|
||||||
|
return box && box.width > 40 && box.height > 40 ? box : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function challengeRoutes(app: FastifyInstance): void {
|
||||||
|
app.get<{ Params: { id: string } }>('/challenge/:id', async (request, reply) => {
|
||||||
|
const challenge = get(request.params.id);
|
||||||
|
if (!challenge) {
|
||||||
|
return reply
|
||||||
|
.code(410)
|
||||||
|
.type('text/html; charset=utf-8')
|
||||||
|
.send(
|
||||||
|
renderError({
|
||||||
|
heading: 'That puzzle has expired',
|
||||||
|
detail: 'Open the link again to get a fresh one.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = await frameBox(challenge);
|
||||||
|
return reply
|
||||||
|
.type('text/html; charset=utf-8')
|
||||||
|
.header('cache-control', 'no-store')
|
||||||
|
.send(renderChallenge(challenge, box));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** One frame of the parked page. */
|
||||||
|
app.get<{ Params: { id: string } }>('/challenge/:id/frame', async (request, reply) => {
|
||||||
|
const challenge = get(request.params.id);
|
||||||
|
if (!challenge) return reply.code(410).send();
|
||||||
|
|
||||||
|
const box = await frameBox(challenge);
|
||||||
|
const shot = await challenge.page
|
||||||
|
.screenshot({ type: 'jpeg', quality: 70, ...(box ? { clip: box } : {}) })
|
||||||
|
.catch(() => null);
|
||||||
|
if (!shot) return reply.code(502).send();
|
||||||
|
|
||||||
|
return reply.type('image/jpeg').header('cache-control', 'no-store').send(shot);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/challenge/:id/state', async (request, reply) => {
|
||||||
|
const challenge = get(request.params.id);
|
||||||
|
if (!challenge) return reply.code(410).send({ gone: true });
|
||||||
|
|
||||||
|
const box = await frameBox(challenge);
|
||||||
|
return reply.header('cache-control', 'no-store').send({
|
||||||
|
solved: await isSolved(challenge),
|
||||||
|
box: box ?? null,
|
||||||
|
returnTo: challenge.returnTo,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Replay one pointer event onto the parked page. */
|
||||||
|
app.post<{ Params: { id: string }; Body: { type: string; x: number; y: number } }>(
|
||||||
|
'/challenge/:id/input',
|
||||||
|
async (request, reply) => {
|
||||||
|
const challenge = get(request.params.id);
|
||||||
|
if (!challenge) return reply.code(410).send({ gone: true });
|
||||||
|
|
||||||
|
const { type, x, y } = request.body;
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||||
|
return reply.code(400).send({ error: 'x and y must be numbers' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mouse = challenge.page.mouse;
|
||||||
|
try {
|
||||||
|
if (type === 'down') {
|
||||||
|
await mouse.move(x, y);
|
||||||
|
await mouse.down();
|
||||||
|
} else if (type === 'move') {
|
||||||
|
await mouse.move(x, y);
|
||||||
|
} else if (type === 'up') {
|
||||||
|
await mouse.move(x, y);
|
||||||
|
await mouse.up();
|
||||||
|
} else {
|
||||||
|
return reply.code(400).send({ error: `unknown event ${type}` });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
request.log.warn({ err: error }, 'challenge input failed');
|
||||||
|
return reply.code(502).send({ error: 'the page went away' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Give up, or finish: close the tab and go back to the post. */
|
||||||
|
app.post<{ Params: { id: string } }>('/challenge/:id/done', async (request, reply) => {
|
||||||
|
const returnTo = await release(request.params.id);
|
||||||
|
return reply.send({ returnTo: returnTo ?? '/' });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { isReady, queueDepth } from '../browser/pool.ts';
|
||||||
|
import { cacheSize } from '../resolve.ts';
|
||||||
|
import { registrySize } from '../media/registry.ts';
|
||||||
|
import { parkedCount } from '../challenge/registry.ts';
|
||||||
|
|
||||||
|
export function healthRoutes(app: FastifyInstance): void {
|
||||||
|
// Liveness: the process is answering. Restarting on anything less would
|
||||||
|
// throw away a warm browser for no reason.
|
||||||
|
app.get('/healthz', async () => ({ ok: true }));
|
||||||
|
|
||||||
|
// Readiness: Chromium is up, so a request routed here can be served
|
||||||
|
// without paying the cold start.
|
||||||
|
app.get('/readyz', async (_request, reply) => {
|
||||||
|
const ready = isReady();
|
||||||
|
return reply.code(ready ? 200 : 503).send({
|
||||||
|
ready,
|
||||||
|
queueDepth: queueDepth(),
|
||||||
|
cachedPosts: cacheSize(),
|
||||||
|
mediaRefs: registrySize(),
|
||||||
|
parkedChallenges: parkedCount(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { currentUserAgent } from '../browser/pool.ts';
|
||||||
|
import { lookupAsset } from '../media/registry.ts';
|
||||||
|
|
||||||
|
/** Headers worth carrying back so the browser can cache and, crucially,
|
||||||
|
* seek within a video. */
|
||||||
|
const PASS_THROUGH = [
|
||||||
|
'content-type',
|
||||||
|
'content-length',
|
||||||
|
'content-range',
|
||||||
|
'accept-ranges',
|
||||||
|
'last-modified',
|
||||||
|
'etag',
|
||||||
|
];
|
||||||
|
|
||||||
|
const FALLBACK_UA =
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
export function mediaRoutes(app: FastifyInstance): void {
|
||||||
|
/**
|
||||||
|
* Streams one upstream asset.
|
||||||
|
*
|
||||||
|
* The id only ever refers to something an adapter registered, so there is
|
||||||
|
* no user-supplied URL here to be tricked into fetching.
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { id: string } }>('/m/:id', async (request, reply) => {
|
||||||
|
const asset = lookupAsset(request.params.id);
|
||||||
|
if (!asset) {
|
||||||
|
return reply.code(404).type('text/plain').send('Unknown media reference — reload the page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...asset.fetchHeaders,
|
||||||
|
'User-Agent': currentUserAgent() ?? FALLBACK_UA,
|
||||||
|
Accept: '*/*',
|
||||||
|
'Accept-Language': 'en-CA,en;q=0.9',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forwarding Range is what makes the native video scrubber work.
|
||||||
|
const range = request.headers.range;
|
||||||
|
if (typeof range === 'string') headers['Range'] = range;
|
||||||
|
|
||||||
|
let upstream: Response;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(asset.url, { headers, redirect: 'follow' });
|
||||||
|
} catch (error) {
|
||||||
|
request.log.warn({ err: error, url: asset.url }, 'media fetch failed');
|
||||||
|
return reply.code(502).type('text/plain').send('Could not reach that media.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstream.ok && upstream.status !== 206) {
|
||||||
|
request.log.warn({ status: upstream.status, url: asset.url }, 'media upstream rejected');
|
||||||
|
return reply.code(502).type('text/plain').send(`Upstream returned ${upstream.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of PASS_THROUGH) {
|
||||||
|
const value = upstream.headers.get(name);
|
||||||
|
if (value) reply.header(name, value);
|
||||||
|
}
|
||||||
|
reply.header('cache-control', 'private, max-age=3600');
|
||||||
|
reply.code(upstream.status);
|
||||||
|
|
||||||
|
if (!upstream.body) return reply.send();
|
||||||
|
return reply.send(Readable.fromWeb(upstream.body as WebReadableStream));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { originalUrlFor, platformByPrefix, platforms } from '../platforms/index.ts';
|
||||||
|
import { renderError } from '../render/error.ts';
|
||||||
|
import { renderPost } from '../render/post.ts';
|
||||||
|
import { renderIndex } from '../render/index-page.ts';
|
||||||
|
import { resolvePost } from '../resolve.ts';
|
||||||
|
import { ChallengeError, ResolveError } from '../types.ts';
|
||||||
|
import { setReturnTo } from '../challenge/registry.ts';
|
||||||
|
|
||||||
|
export function postRoutes(app: FastifyInstance): void {
|
||||||
|
app.get('/', async (_request, reply) =>
|
||||||
|
reply.type('text/html; charset=utf-8').send(renderIndex(platforms)),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { prefix: string; '*': string } }>(
|
||||||
|
'/:prefix/*',
|
||||||
|
async (request, reply) => {
|
||||||
|
const spec = platformByPrefix(request.params.prefix);
|
||||||
|
if (!spec) {
|
||||||
|
return reply
|
||||||
|
.code(404)
|
||||||
|
.type('text/html; charset=utf-8')
|
||||||
|
.send(
|
||||||
|
renderError({
|
||||||
|
heading: 'Not a platform antisocial knows',
|
||||||
|
detail: `No adapter is registered for "/${request.params.prefix}". Known prefixes: ${platforms
|
||||||
|
.map((p) => `/${p.prefix}`)
|
||||||
|
.join(', ')}.`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryIndex = request.url.indexOf('?');
|
||||||
|
const search = queryIndex === -1 ? '' : request.url.slice(queryIndex);
|
||||||
|
const pathRest = request.params['*'];
|
||||||
|
|
||||||
|
let originalUrl: string;
|
||||||
|
try {
|
||||||
|
originalUrl = originalUrlFor(spec, pathRest, search);
|
||||||
|
} catch {
|
||||||
|
return reply
|
||||||
|
.code(400)
|
||||||
|
.type('text/html; charset=utf-8')
|
||||||
|
.send(
|
||||||
|
renderError({
|
||||||
|
platform: spec.id,
|
||||||
|
platformLabel: spec.label,
|
||||||
|
heading: 'That URL did not make sense',
|
||||||
|
detail: `Could not rebuild a ${spec.label} link from "/${spec.prefix}/${pathRest}".`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = await resolvePost(spec, originalUrl, pathRest);
|
||||||
|
return reply
|
||||||
|
.type('text/html; charset=utf-8')
|
||||||
|
.header('cache-control', 'private, max-age=300')
|
||||||
|
.send(renderPost(post));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChallengeError) {
|
||||||
|
// Send them to the puzzle rather than reporting it. Solving it is
|
||||||
|
// the one thing that actually gets the post on screen.
|
||||||
|
setReturnTo(error.challengeId, request.url);
|
||||||
|
return reply.redirect(`/challenge/${error.challengeId}`, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = error instanceof ResolveError ? error.message : 'Something went wrong.';
|
||||||
|
request.log.warn({ err: error, originalUrl }, 'resolve failed');
|
||||||
|
|
||||||
|
// Never a bare error page: the link still has to be one tap away.
|
||||||
|
return reply
|
||||||
|
.code(502)
|
||||||
|
.type('text/html; charset=utf-8')
|
||||||
|
.send(
|
||||||
|
renderError({
|
||||||
|
platform: spec.id,
|
||||||
|
platformLabel: spec.label,
|
||||||
|
originalUrl,
|
||||||
|
heading: `Could not read that ${spec.label} post`,
|
||||||
|
detail,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import { shutdown, warmUp } from './browser/pool.ts';
|
||||||
|
import { config } from './config.ts';
|
||||||
|
import { challengeRoutes } from './routes/challenge.ts';
|
||||||
|
import { healthRoutes } from './routes/health.ts';
|
||||||
|
import { mediaRoutes } from './routes/media.ts';
|
||||||
|
import { postRoutes } from './routes/post.ts';
|
||||||
|
import { renderError } from './render/error.ts';
|
||||||
|
|
||||||
|
export function build() {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: config.logLevel },
|
||||||
|
trustProxy: true,
|
||||||
|
// Instagram links arrive with a trailing slash, X links without one.
|
||||||
|
routerOptions: { ignoreTrailingSlash: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
app.register(fastifyStatic, {
|
||||||
|
root: join(import.meta.dirname, '..', 'public'),
|
||||||
|
prefix: '/static/',
|
||||||
|
maxAge: '1h',
|
||||||
|
});
|
||||||
|
|
||||||
|
healthRoutes(app);
|
||||||
|
challengeRoutes(app);
|
||||||
|
mediaRoutes(app);
|
||||||
|
postRoutes(app);
|
||||||
|
|
||||||
|
app.setNotFoundHandler(async (_request, reply) =>
|
||||||
|
reply
|
||||||
|
.code(404)
|
||||||
|
.type('text/html; charset=utf-8')
|
||||||
|
.send(renderError({ heading: 'Nothing here', detail: 'That is not a post URL.' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const app = build();
|
||||||
|
|
||||||
|
// Start Chromium alongside the server rather than on the first request,
|
||||||
|
// so readiness means "can serve now".
|
||||||
|
void warmUp().catch((error: unknown) => {
|
||||||
|
app.log.error({ err: error }, 'browser failed to start');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||||
|
process.once(signal, () => {
|
||||||
|
app.log.info({ signal }, 'shutting down');
|
||||||
|
void app
|
||||||
|
.close()
|
||||||
|
.then(() => shutdown())
|
||||||
|
.then(() => process.exit(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.listen({ host: config.host, port: config.port });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run the server when this file is the entry point, so tests can
|
||||||
|
// import `build()` without opening a socket.
|
||||||
|
if (process.argv[1] && import.meta.filename === process.argv[1]) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/** The five platforms v1 understands. */
|
||||||
|
export type PlatformId = 'x' | 'threads' | 'instagram' | 'tiktok' | 'bluesky';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something fetchable that lives on someone else's CDN.
|
||||||
|
*
|
||||||
|
* Most of these get rewritten to a `/m/` proxy URL before they reach the
|
||||||
|
* page, because Instagram and TikTok reject requests that arrive without the
|
||||||
|
* right `Referer` and cookies, and because proxying keeps the viewer's
|
||||||
|
* browser from talking to the platform at all.
|
||||||
|
*/
|
||||||
|
export type Asset = {
|
||||||
|
url: string;
|
||||||
|
/** Headers the upstream CDN insists on. Held server-side, never in the page. */
|
||||||
|
fetchHeaders?: Record<string, string>;
|
||||||
|
/** Point the page straight at the CDN instead of proxying. Used for HLS,
|
||||||
|
* where proxying would mean rewriting playlists and every segment. */
|
||||||
|
direct?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaBase = Asset & {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Media =
|
||||||
|
| (MediaBase & { kind: 'image'; alt?: string })
|
||||||
|
| (MediaBase & {
|
||||||
|
kind: 'video';
|
||||||
|
poster?: Asset;
|
||||||
|
durationSec?: number;
|
||||||
|
/** An HLS playlist rather than a progressive file. Safari plays these
|
||||||
|
* natively; other browsers get a note. */
|
||||||
|
hls?: boolean;
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Author = {
|
||||||
|
handle: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: Asset;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single shape every adapter produces and the renderer consumes. Adding
|
||||||
|
* a platform means producing one of these; nothing downstream changes.
|
||||||
|
*/
|
||||||
|
export type Post = {
|
||||||
|
platform: PlatformId;
|
||||||
|
/** Human label for the badge: "X", "Threads", ... */
|
||||||
|
platformLabel: string;
|
||||||
|
/** Canonical original URL, cleaned of tracking parameters. This is what
|
||||||
|
* the copy button hands back. */
|
||||||
|
originalUrl: string;
|
||||||
|
author: Author;
|
||||||
|
text?: string;
|
||||||
|
/** Fixed per platform: the ones that lead with words put the text above
|
||||||
|
* the media, the ones that lead with pictures put it below. */
|
||||||
|
textPosition: 'above' | 'below';
|
||||||
|
media: Media[];
|
||||||
|
/** ISO 8601. */
|
||||||
|
postedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export class ResolveError extends Error {
|
||||||
|
readonly platform: PlatformId;
|
||||||
|
readonly originalUrl: string;
|
||||||
|
|
||||||
|
constructor(message: string, platform: PlatformId, originalUrl: string, options?: ErrorOptions) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = 'ResolveError';
|
||||||
|
this.platform = platform;
|
||||||
|
this.originalUrl = originalUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A platform answered with a human-verification puzzle rather than the post.
|
||||||
|
*
|
||||||
|
* The page showing it is parked and still open, so the viewer can be handed
|
||||||
|
* the puzzle to solve. Solving it leaves the resulting cookie in the shared
|
||||||
|
* browser context, which is what makes the retry work.
|
||||||
|
*/
|
||||||
|
export class ChallengeError extends ResolveError {
|
||||||
|
readonly challengeId: string;
|
||||||
|
|
||||||
|
constructor(challengeId: string, platform: PlatformId, originalUrl: string) {
|
||||||
|
super('A verification puzzle is in the way.', platform, originalUrl);
|
||||||
|
this.name = 'ChallengeError';
|
||||||
|
this.challengeId = challengeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { mediaFromEmbed, toPost } from '../src/platforms/bluesky.ts';
|
||||||
|
import { fixture } from './helpers.ts';
|
||||||
|
|
||||||
|
const URL_ = 'https://bsky.app/profile/example/post/abc';
|
||||||
|
|
||||||
|
test('a text-only post carries no media', () => {
|
||||||
|
const post = toPost(fixture('bluesky/text-only.json'), URL_);
|
||||||
|
assert.equal(post.platform, 'bluesky');
|
||||||
|
assert.equal(post.textPosition, 'above');
|
||||||
|
assert.deepEqual(post.media, []);
|
||||||
|
assert.ok(post.text);
|
||||||
|
assert.match(post.author.handle, /^@/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an image post keeps every image, in order, with alt text', () => {
|
||||||
|
const post = toPost(fixture('bluesky/images.json'), URL_);
|
||||||
|
assert.ok(post.media.length > 1, 'expected a multi-image post');
|
||||||
|
assert.ok(post.media.every((m) => m.kind === 'image'));
|
||||||
|
assert.ok(post.media.every((m) => m.url.startsWith('https://')));
|
||||||
|
assert.ok(post.media.some((m) => m.kind === 'image' && m.alt));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a video post is a direct HLS playlist with a poster', () => {
|
||||||
|
const post = toPost(fixture('bluesky/video.json'), URL_);
|
||||||
|
const [item] = post.media;
|
||||||
|
assert.equal(item?.kind, 'video');
|
||||||
|
assert.ok(item?.kind === 'video' && item.hls);
|
||||||
|
// Proxying HLS would mean rewriting the manifest, so it is linked direct.
|
||||||
|
assert.ok(item?.direct);
|
||||||
|
assert.ok(item?.kind === 'video' && item.poster);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a quote post with media reaches through the nested embed', () => {
|
||||||
|
const media = mediaFromEmbed({
|
||||||
|
$type: 'app.bsky.embed.recordWithMedia#view',
|
||||||
|
media: { $type: 'app.bsky.embed.images#view', images: [{ fullsize: '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', () => {
|
||||||
|
const media = mediaFromEmbed({
|
||||||
|
$type: 'app.bsky.embed.record#view',
|
||||||
|
record: {
|
||||||
|
embeds: [{ $type: 'app.bsky.embed.images#view', images: [{ fullsize: 'https://cdn/q.jpg' }] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(media, [{ kind: 'image', url: 'https://cdn/q.jpg' }]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import type { Page } from 'playwright';
|
||||||
|
import { get, park, parkedCount, release, setReturnTo } from '../src/challenge/registry.ts';
|
||||||
|
|
||||||
|
/** Only the two methods the registry touches. */
|
||||||
|
function stubPage(): Page & { closed: boolean } {
|
||||||
|
const page = {
|
||||||
|
closed: false,
|
||||||
|
close: async () => {
|
||||||
|
page.closed = true;
|
||||||
|
},
|
||||||
|
evaluate: async () => true,
|
||||||
|
};
|
||||||
|
return page as unknown as Page & { closed: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parkOne(page = stubPage()): Promise<string> {
|
||||||
|
return park({ page, platform: 'tiktok', platformLabel: 'TikTok', originalUrl: 'https://t/1' });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a parked challenge round-trips, and its id is not guessable', async () => {
|
||||||
|
const id = await parkOne();
|
||||||
|
assert.match(id, /^[A-Za-z0-9_-]{12}$/);
|
||||||
|
assert.equal(get(id)?.originalUrl, 'https://t/1');
|
||||||
|
await release(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('releasing closes the tab and hands back where to go next', async () => {
|
||||||
|
const page = stubPage();
|
||||||
|
const id = await parkOne(page);
|
||||||
|
setReturnTo(id, '/tiktok/@a/video/1');
|
||||||
|
|
||||||
|
assert.equal(await release(id), '/tiktok/@a/video/1');
|
||||||
|
assert.equal(page.closed, true, 'the browser tab must not be left open');
|
||||||
|
assert.equal(get(id), undefined);
|
||||||
|
assert.equal(await release(id), undefined, 'releasing twice is harmless');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parking more than the cap closes the oldest rather than hoarding tabs', async () => {
|
||||||
|
const before = parkedCount();
|
||||||
|
const first = stubPage();
|
||||||
|
const ids = [await parkOne(first)];
|
||||||
|
for (let i = 0; i < 3; i += 1) ids.push(await parkOne());
|
||||||
|
|
||||||
|
assert.ok(parkedCount() <= 3, `expected at most 3 parked, got ${parkedCount()}`);
|
||||||
|
assert.equal(first.closed, true, 'the evicted tab must be closed');
|
||||||
|
assert.equal(get(ids[0] as string), undefined);
|
||||||
|
|
||||||
|
for (const id of ids) await release(id);
|
||||||
|
assert.equal(parkedCount(), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown id is undefined rather than a throw', () => {
|
||||||
|
assert.equal(get('nope'), undefined);
|
||||||
|
});
|
||||||
Vendored
+152
@@ -0,0 +1,152 @@
|
|||||||
|
{
|
||||||
|
"uri": "at://did:plc:ydfn75us2pk4dhfvqejyla2p/app.bsky.feed.post/3mtk7inwsws2k",
|
||||||
|
"cid": "bafyreibs4xcnfccfeqkrexv7bgquzypups3ji35c3ajjs6gcswkq33ali4",
|
||||||
|
"author": {
|
||||||
|
"did": "did:plc:ydfn75us2pk4dhfvqejyla2p",
|
||||||
|
"handle": "danielle.bsky.team",
|
||||||
|
"displayName": "danielleyuhan",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:ydfn75us2pk4dhfvqejyla2p/bafkreib7zbjrocsnw5ht6fy5kepc7454britwohmn2zrj5rlsicmzisrmq",
|
||||||
|
"associated": {
|
||||||
|
"activitySubscription": {
|
||||||
|
"allowSubscriptions": "followers"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"labels": [],
|
||||||
|
"createdAt": "2026-02-13T06:30:50.945Z",
|
||||||
|
"verification": {
|
||||||
|
"verifications": [
|
||||||
|
{
|
||||||
|
"issuer": "did:plc:z72i7hdynmk6r22z27h6tvur",
|
||||||
|
"issuerDisplayName": "Bluesky",
|
||||||
|
"issuerHandle": "bsky.app",
|
||||||
|
"uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.graph.verification/3mtkf2xrxma2b",
|
||||||
|
"isValid": true,
|
||||||
|
"createdAt": "2026-08-20T23:19:53.007Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"verifiedStatus": "valid",
|
||||||
|
"trustedVerifierStatus": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"$type": "app.bsky.feed.post",
|
||||||
|
"createdAt": "2026-08-20T21:40:10.285Z",
|
||||||
|
"embed": {
|
||||||
|
"$type": "app.bsky.embed.images",
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"alt": "\"All\" tab that shows all your notifications in chronological order.",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 1704,
|
||||||
|
"width": 786
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"$type": "blob",
|
||||||
|
"ref": {
|
||||||
|
"$link": "bafkreiaifo3c4pchs2qtgbyblpt3r35eqngodxzckksiti6ufrnw7e3uoq"
|
||||||
|
},
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"size": 451248
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"$type": "blob",
|
||||||
|
"ref": {
|
||||||
|
"$link": "bafkreiezmgsafcbrgbqcaoxf4uu6qwcb4vxsjngtwpoylznut52imhdjue"
|
||||||
|
},
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"size": 261221
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alt": "\"Conversations\" tab that allows you to view mentions, replies, and quotes all in one place. ",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 1704,
|
||||||
|
"width": 786
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"$type": "blob",
|
||||||
|
"ref": {
|
||||||
|
"$link": "bafkreieigolasgenhs4guectwhrprc4te6el7fosn5ayzexpxjh6tk565i"
|
||||||
|
},
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"size": 417277
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alt": "\"Activity\" tab that shows all the posts from accounts you've subscribed to.",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 1704,
|
||||||
|
"width": 786
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"$type": "blob",
|
||||||
|
"ref": {
|
||||||
|
"$link": "bafkreihtzrgrltxhxcnrzp6zkn2bkkhyd5fhu5hp3xtypf4h24q5x5ygei"
|
||||||
|
},
|
||||||
|
"mimeType": "image/jpeg",
|
||||||
|
"size": 287835
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"langs": [
|
||||||
|
"en"
|
||||||
|
],
|
||||||
|
"text": "We're redesigning the notifications tab! 🔔 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. 🧵"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"bookmarkCount": 86,
|
||||||
|
"replyCount": 115,
|
||||||
|
"repostCount": 182,
|
||||||
|
"likeCount": 1392,
|
||||||
|
"quoteCount": 55,
|
||||||
|
"indexedAt": "2026-08-20T21:40:13.169Z",
|
||||||
|
"labels": []
|
||||||
|
}
|
||||||
Vendored
+50
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3mtwpsbl3wk2v",
|
||||||
|
"cid": "bafyreibqrn7hy5pkmznev5bvj6rf6vlgljjdgewwgx6t4tzgteqd4esq5u",
|
||||||
|
"author": {
|
||||||
|
"did": "did:plc:z72i7hdynmk6r22z27h6tvur",
|
||||||
|
"handle": "bsky.app",
|
||||||
|
"displayName": "Bluesky",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihwihm6kpd6zuwhhlro75p5qks5qtrcu55jp3gddbfjsieiv7wuka",
|
||||||
|
"associated": {
|
||||||
|
"chat": {
|
||||||
|
"allowIncoming": "none"
|
||||||
|
},
|
||||||
|
"activitySubscription": {
|
||||||
|
"allowSubscriptions": "followers"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"labels": [],
|
||||||
|
"createdAt": "2023-04-12T04:53:57.057Z",
|
||||||
|
"verification": {
|
||||||
|
"verifications": [],
|
||||||
|
"verifiedStatus": "none",
|
||||||
|
"trustedVerifierStatus": "valid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"$type": "app.bsky.feed.post",
|
||||||
|
"createdAt": "2026-08-25T21:03:49.595Z",
|
||||||
|
"langs": [
|
||||||
|
"en"
|
||||||
|
],
|
||||||
|
"reply": {
|
||||||
|
"parent": {
|
||||||
|
"cid": "bafyreicuxw6ubwms6ffbu4nlsyjucc34k7e55tsljawmtmx5aazrhkul3q",
|
||||||
|
"uri": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3mtwmzymnwk27"
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"cid": "bafyreiaa3drvjbsesqvewykcn5sth3nog2kuaixlo3exqz4k4bds3zz7dm",
|
||||||
|
"uri": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3mtwmuirars2a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text": "was the grink there?"
|
||||||
|
},
|
||||||
|
"bookmarkCount": 0,
|
||||||
|
"replyCount": 0,
|
||||||
|
"repostCount": 5,
|
||||||
|
"likeCount": 131,
|
||||||
|
"quoteCount": 1,
|
||||||
|
"indexedAt": "2026-08-25T21:03:50.766Z",
|
||||||
|
"labels": []
|
||||||
|
}
|
||||||
Vendored
+145
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"uri": "at://did:plc:5amsebbtpuqieuxuqadmmvcv/app.bsky.feed.post/3mtm3ff75lc2d",
|
||||||
|
"cid": "bafyreibqwq6uhqpxnpynzqcv5ok5vkgy2r3hnkwf72z4mgtguehxcngg2u",
|
||||||
|
"author": {
|
||||||
|
"did": "did:plc:5amsebbtpuqieuxuqadmmvcv",
|
||||||
|
"handle": "debbieohi.com",
|
||||||
|
"displayName": "Debbie Ridpath Ohi",
|
||||||
|
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:5amsebbtpuqieuxuqadmmvcv/bafkreih64zpkkuwxeixoin2cdcynlnyi5khtkgq7xr65iruiu6dgiqjtuq",
|
||||||
|
"associated": {
|
||||||
|
"chat": {
|
||||||
|
"allowIncoming": "following"
|
||||||
|
},
|
||||||
|
"activitySubscription": {
|
||||||
|
"allowSubscriptions": "followers"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"labels": [],
|
||||||
|
"createdAt": "2023-06-14T13:29:27.322Z",
|
||||||
|
"verification": {
|
||||||
|
"verifications": [
|
||||||
|
{
|
||||||
|
"issuer": "did:plc:z72i7hdynmk6r22z27h6tvur",
|
||||||
|
"issuerDisplayName": "Bluesky",
|
||||||
|
"issuerHandle": "bsky.app",
|
||||||
|
"uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.graph.verification/3lsgx22iysw26",
|
||||||
|
"isValid": true,
|
||||||
|
"createdAt": "2025-06-25T15:39:26.961Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"verifiedStatus": "valid",
|
||||||
|
"trustedVerifierStatus": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"$type": "app.bsky.feed.post",
|
||||||
|
"createdAt": "2026-08-21T15:32:04.968Z",
|
||||||
|
"embed": {
|
||||||
|
"$type": "app.bsky.embed.video",
|
||||||
|
"alt": "Starting with logging into Bluesky, video shows some of the different apps you can login into with one Atmosphere account. Ends with an overview of some of Debbie's Atmosphere blogs on her website.",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 782,
|
||||||
|
"width": 1072
|
||||||
|
},
|
||||||
|
"presentation": "default",
|
||||||
|
"video": {
|
||||||
|
"$type": "blob",
|
||||||
|
"ref": {
|
||||||
|
"$link": "bafkreiedlcs5ud6nw7zicturrepbmx6g3d6fq7j32p6hp34t2knbxownwm"
|
||||||
|
},
|
||||||
|
"mimeType": "video/mp4",
|
||||||
|
"size": 6485809
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"facets": [
|
||||||
|
{
|
||||||
|
"$type": "app.bsky.richtext.facet",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"$type": "app.bsky.richtext.facet#mention",
|
||||||
|
"did": "did:plc:btxrwcaeyodrap5mnjw2fvmz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index": {
|
||||||
|
"byteEnd": 61,
|
||||||
|
"byteStart": 49
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"$type": "app.bsky.richtext.facet#link",
|
||||||
|
"uri": "https://debbieohi.leaflet.pub/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index": {
|
||||||
|
"byteEnd": 96,
|
||||||
|
"byteStart": 75
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "app.bsky.richtext.facet",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"$type": "app.bsky.richtext.facet#mention",
|
||||||
|
"did": "did:plc:kydzcmnywraao2srchqgwj5c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index": {
|
||||||
|
"byteEnd": 213,
|
||||||
|
"byteStart": 196
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"$type": "app.bsky.richtext.facet#link",
|
||||||
|
"uri": "https://lab.leaflet.pub/3mrq6ma5dgs2x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index": {
|
||||||
|
"byteEnd": 296,
|
||||||
|
"byteStart": 267
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"langs": [
|
||||||
|
"en"
|
||||||
|
],
|
||||||
|
"text": "Here's a short video I'm hoping to include in my @leaflet.pub blog post at debbieohi.leaflet.pub showing a glimpse of how useful it is to have an Atmosphere account. I will also include a link to @cozylittle.house's excellent non-techie explainer as well, of course! lab.leaflet.pub/3mrq6ma5dgs2x"
|
||||||
|
},
|
||||||
|
"embed": {
|
||||||
|
"cid": "bafkreiedlcs5ud6nw7zicturrepbmx6g3d6fq7j32p6hp34t2knbxownwm",
|
||||||
|
"playlist": "https://video.bsky.app/watch/did%3Aplc%3A5amsebbtpuqieuxuqadmmvcv/bafkreiedlcs5ud6nw7zicturrepbmx6g3d6fq7j32p6hp34t2knbxownwm/playlist.m3u8",
|
||||||
|
"thumbnail": "https://video.bsky.app/watch/did%3Aplc%3A5amsebbtpuqieuxuqadmmvcv/bafkreiedlcs5ud6nw7zicturrepbmx6g3d6fq7j32p6hp34t2knbxownwm/thumbnail.jpg",
|
||||||
|
"alt": "Starting with logging into Bluesky, video shows some of the different apps you can login into with one Atmosphere account. Ends with an overview of some of Debbie's Atmosphere blogs on her website.",
|
||||||
|
"aspectRatio": {
|
||||||
|
"height": 782,
|
||||||
|
"width": 1072
|
||||||
|
},
|
||||||
|
"presentation": "default",
|
||||||
|
"$type": "app.bsky.embed.video#view"
|
||||||
|
},
|
||||||
|
"bookmarkCount": 170,
|
||||||
|
"replyCount": 17,
|
||||||
|
"repostCount": 70,
|
||||||
|
"likeCount": 579,
|
||||||
|
"quoteCount": 7,
|
||||||
|
"indexedAt": "2026-08-21T15:32:06.364Z",
|
||||||
|
"labels": [],
|
||||||
|
"threadgate": {
|
||||||
|
"uri": "at://did:plc:5amsebbtpuqieuxuqadmmvcv/app.bsky.feed.threadgate/3mtm3ff75lc2d",
|
||||||
|
"cid": "bafyreiazkhpopd574o5g7w7ve6glbgcszc56qnkq6mwchcwcce56hwik3a",
|
||||||
|
"record": {
|
||||||
|
"$type": "app.bsky.feed.threadgate",
|
||||||
|
"createdAt": "2026-08-23T20:34:37.154Z",
|
||||||
|
"hiddenReplies": [
|
||||||
|
"at://did:plc:vxcfr63bjo35yhmw7fv6tbjg/app.bsky.feed.post/3mtmy332bj22w",
|
||||||
|
"at://did:plc:syipzh6g64tcl4nseh74f45a/app.bsky.feed.post/3mtqvrdbpq52t",
|
||||||
|
"at://did:plc:syipzh6g64tcl4nseh74f45a/app.bsky.feed.post/3mtofaxiqb52j"
|
||||||
|
],
|
||||||
|
"post": "at://did:plc:5amsebbtpuqieuxuqadmmvcv/app.bsky.feed.post/3mtm3ff75lc2d"
|
||||||
|
},
|
||||||
|
"lists": []
|
||||||
|
}
|
||||||
|
}
|
||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
{
|
||||||
|
"__typename": "GraphSidecar",
|
||||||
|
"id": "3971661362551659979",
|
||||||
|
"shortcode": "DceLPdrCR3L",
|
||||||
|
"is_video": false,
|
||||||
|
"display_url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQHENO_a4Udt8__LCyEWKEIteQsKAsa1HCPkyQBfo8dd1Q&oe=6A94BFEA",
|
||||||
|
"display_resources": [
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQHENO_a4Udt8__LCyEWKEIteQsKAsa1HCPkyQBfo8dd1Q&oe=6A94BFEA",
|
||||||
|
"config_width": 1364,
|
||||||
|
"config_height": 908
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s1080x1080_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFyxzet-Bvn47Bum91lCCTVDn3-WmjGu_RLReq4pGk6-A&oe=6A94BFEA",
|
||||||
|
"config_width": 1080,
|
||||||
|
"config_height": 719
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s720x720_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQEMZjAC374hJ-jS-HfRWROOXPKOEG9PUBXILghrh2Xwtg&oe=6A94BFEA",
|
||||||
|
"config_width": 720,
|
||||||
|
"config_height": 479
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s640x640_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFl2iKYLmOVQWouXPQ-WC99T1-uJbWLqn_6-XdZzXIkIQ&oe=6A94BFEA",
|
||||||
|
"config_width": 640,
|
||||||
|
"config_height": 426
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s480x480_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFgCi8kfR45EZkGqSxNrtxwKHccAnCbtfz3uSj5KssDww&oe=6A94BFEA",
|
||||||
|
"config_width": 480,
|
||||||
|
"config_height": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s320x320_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFNs0eB7pTEPmj5f0JGNpxPo4Xi1rZwM1hXVI2xf0ITAw&oe=6A94BFEA",
|
||||||
|
"config_width": 320,
|
||||||
|
"config_height": 213
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s240x240_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQGdIdhbiNnICX5wye1jv0Hosjy0vI3uq5Tph2YDdKFI0g&oe=6A94BFEA",
|
||||||
|
"config_width": 240,
|
||||||
|
"config_height": 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s908x908_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQG9Okp9gREd6nOSDexr06QVTOl_WPs0n6slJ-xrZAQCeg&oe=6A94BFEA",
|
||||||
|
"config_width": 908,
|
||||||
|
"config_height": 908
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s750x750_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQE1UNJQ2JdQv2UCVAr4qTIZq2AJ4XufY65RtgjJktYITw&oe=6A94BFEA",
|
||||||
|
"config_width": 750,
|
||||||
|
"config_height": 750
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s640x640_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFmQ9ebQCtceFJDKlpQixIxHzSGaxiYXsgBatOlxLOdXA&oe=6A94BFEA",
|
||||||
|
"config_width": 640,
|
||||||
|
"config_height": 640
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s480x480_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFRVzAIhR03YexTHm3XVLYlLCqUUdrBYHD3RLkm2Ash5w&oe=6A94BFEA",
|
||||||
|
"config_width": 480,
|
||||||
|
"config_height": 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s320x320_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQG33AphQ4tOnxZxhd-iM789mEG0D5trji9ct6ZZHRS9yQ&oe=6A94BFEA",
|
||||||
|
"config_width": 320,
|
||||||
|
"config_height": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s240x240_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQGRKjDYI8TwDxAeeONm250iy_J4M_9giWZWT4B-rmv39g&oe=6A94BFEA",
|
||||||
|
"config_width": 240,
|
||||||
|
"config_height": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s150x150_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQGzTetYjD-R_hvJRlFcwJ_mPLaqXNJl_rWzydmaURmB8w&oe=6A94BFEA",
|
||||||
|
"config_width": 150,
|
||||||
|
"config_height": 150
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dimensions": {
|
||||||
|
"height": 908,
|
||||||
|
"width": 1364
|
||||||
|
},
|
||||||
|
"accessibility_caption": null,
|
||||||
|
"edge_media_to_caption": {
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"text": "Look at this coronagraph!\n \nOur newest space telescope, the Nancy Grace Roman Space Telescope, will launch equipped with tools to help it study the cosmos. The silver wedge in the cage-like instrument carrier is a tool that will help Roman spot and study exoplanets orbiting nearby stars—it’s called the Coronagraph.\n \nThe Coronagraph works by blocking the glare of the stars Roman sees. Like how you might block the Sun with your hand when trying to see a bird, plane, or anything else in the sky, Roman can more easily detect exoplanets when obscuring the blinding glint of their host stars.\n \nCoronagraphs aren’t new technology—scientists have used them to study our Sun for almost 100 years. But Roman’s Coronagraph is equipped with complex engineering to help it perform better than any other coronagraph that has ever flown in space.\n \nCredit: NASA\n \n#NASA #Roman #Universe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"edge_media_to_comment": {
|
||||||
|
"count": 272
|
||||||
|
},
|
||||||
|
"edge_liked_by": {
|
||||||
|
"count": 74100
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"id": "528817151",
|
||||||
|
"username": "nasa",
|
||||||
|
"is_verified": true,
|
||||||
|
"profile_pic_url": "https://scontent-lga3-3.cdninstagram.com/v/t51.2885-19/29090066_159271188110124_1152068159029641216_n.jpg?stp=dst-jpg_s100x100_tt6&_nc_cat=1&ccb=7-5&_nc_sid=bf7eb4&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLnd3dy4xMDgwLkMzIn0%3D&_nc_ohc=eSDslL9gydgQ7kNvwG1A2hX&_nc_oc=AdrZJz0cfWbvDcNQelmuxcd370rPyiVu9UcH8ReWla7ZYdui1PVgtnnAinFra-LbEfI&_nc_zt=24&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_ss=7b689&oh=00_AQHvOWTU0EAhOawBTwNSMYf3SZdZV7TU36Nmp91V_bq6_A&oe=6A94C3E9",
|
||||||
|
"edge_followed_by": {
|
||||||
|
"count": 104423332
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edge_sidecar_to_children": {
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"id": "3971660663799191454",
|
||||||
|
"shortcode": "DceLFS6JJee",
|
||||||
|
"is_video": false,
|
||||||
|
"display_url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQHENO_a4Udt8__LCyEWKEIteQsKAsa1HCPkyQBfo8dd1Q&oe=6A94BFEA",
|
||||||
|
"display_resources": [
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQHENO_a4Udt8__LCyEWKEIteQsKAsa1HCPkyQBfo8dd1Q&oe=6A94BFEA",
|
||||||
|
"config_width": 1364,
|
||||||
|
"config_height": 908
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s1080x1080_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFyxzet-Bvn47Bum91lCCTVDn3-WmjGu_RLReq4pGk6-A&oe=6A94BFEA",
|
||||||
|
"config_width": 1080,
|
||||||
|
"config_height": 719
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s720x720_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQEMZjAC374hJ-jS-HfRWROOXPKOEG9PUBXILghrh2Xwtg&oe=6A94BFEA",
|
||||||
|
"config_width": 720,
|
||||||
|
"config_height": 479
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s640x640_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFl2iKYLmOVQWouXPQ-WC99T1-uJbWLqn_6-XdZzXIkIQ&oe=6A94BFEA",
|
||||||
|
"config_width": 640,
|
||||||
|
"config_height": 426
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s480x480_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFgCi8kfR45EZkGqSxNrtxwKHccAnCbtfz3uSj5KssDww&oe=6A94BFEA",
|
||||||
|
"config_width": 480,
|
||||||
|
"config_height": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s320x320_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFNs0eB7pTEPmj5f0JGNpxPo4Xi1rZwM1hXVI2xf0ITAw&oe=6A94BFEA",
|
||||||
|
"config_width": 320,
|
||||||
|
"config_height": 213
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=dst-jpg_e35_s240x240_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQGdIdhbiNnICX5wye1jv0Hosjy0vI3uq5Tph2YDdKFI0g&oe=6A94BFEA",
|
||||||
|
"config_width": 240,
|
||||||
|
"config_height": 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s908x908_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQG9Okp9gREd6nOSDexr06QVTOl_WPs0n6slJ-xrZAQCeg&oe=6A94BFEA",
|
||||||
|
"config_width": 908,
|
||||||
|
"config_height": 908
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s750x750_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQE1UNJQ2JdQv2UCVAr4qTIZq2AJ4XufY65RtgjJktYITw&oe=6A94BFEA",
|
||||||
|
"config_width": 750,
|
||||||
|
"config_height": 750
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s640x640_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFmQ9ebQCtceFJDKlpQixIxHzSGaxiYXsgBatOlxLOdXA&oe=6A94BFEA",
|
||||||
|
"config_width": 640,
|
||||||
|
"config_height": 640
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s480x480_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFRVzAIhR03YexTHm3XVLYlLCqUUdrBYHD3RLkm2Ash5w&oe=6A94BFEA",
|
||||||
|
"config_width": 480,
|
||||||
|
"config_height": 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s320x320_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQG33AphQ4tOnxZxhd-iM789mEG0D5trji9ct6ZZHRS9yQ&oe=6A94BFEA",
|
||||||
|
"config_width": 320,
|
||||||
|
"config_height": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s240x240_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQGRKjDYI8TwDxAeeONm250iy_J4M_9giWZWT4B-rmv39g&oe=6A94BFEA",
|
||||||
|
"config_width": 240,
|
||||||
|
"config_height": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786889295_18641156686049152_5453435744993835300_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s150x150_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2Mzc5OTE5MTQ1NA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=R6VQ3u3XLOwQ7kNvwGofzlB&_nc_oc=AdqRAmGwbMzNQTry_t0dtmXiURsAFCOz0YoYlvKBVUuMnGK_sZpdtiMncuzQi5cs_2c&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQGzTetYjD-R_hvJRlFcwJ_mPLaqXNJl_rWzydmaURmB8w&oe=6A94BFEA",
|
||||||
|
"config_width": 150,
|
||||||
|
"config_height": 150
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dimensions": {
|
||||||
|
"height": 908,
|
||||||
|
"width": 1364
|
||||||
|
},
|
||||||
|
"accessibility_caption": null,
|
||||||
|
"owner": {
|
||||||
|
"id": "528817151",
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"id": "3971660664185206263",
|
||||||
|
"shortcode": "DceLFTRJrX3",
|
||||||
|
"is_video": false,
|
||||||
|
"display_url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQEMl2DVgzFhWvfhRRgg_Hm7AJnBU9RfMOzo0W_DVxt5yw&oe=6A94C2C0",
|
||||||
|
"display_resources": [
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQEMl2DVgzFhWvfhRRgg_Hm7AJnBU9RfMOzo0W_DVxt5yw&oe=6A94C2C0",
|
||||||
|
"config_width": 1364,
|
||||||
|
"config_height": 908
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=dst-jpg_e35_s1080x1080_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFryNr_MaL3zh0O00VRjoANLqnOtOg6GbCqfK_rOAiOQQ&oe=6A94C2C0",
|
||||||
|
"config_width": 1080,
|
||||||
|
"config_height": 719
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=dst-jpg_e35_s720x720_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQGDSv6CTBlPEkbSA_E0AGXTkZYPpVAbsQreCpZ4O1KDBA&oe=6A94C2C0",
|
||||||
|
"config_width": 720,
|
||||||
|
"config_height": 479
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=dst-jpg_e35_s640x640_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQF9wBkdKAsd7ywX-RFpJqridiAV3zZlaPEyL5rFrNEdOg&oe=6A94C2C0",
|
||||||
|
"config_width": 640,
|
||||||
|
"config_height": 426
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=dst-jpg_e35_s480x480_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFeyGsx7wARo3hLAaT8ZudavDWA6p0AjhRHvJXzFZqj8A&oe=6A94C2C0",
|
||||||
|
"config_width": 480,
|
||||||
|
"config_height": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=dst-jpg_e35_s320x320_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFO9O6sIImazeRzOan_tpz--uhJjD25F_iK3UncxhY6gw&oe=6A94C2C0",
|
||||||
|
"config_width": 320,
|
||||||
|
"config_height": 213
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=dst-jpg_e35_s240x240_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFQ6lTp1FP58L9iR_nIB0psoowPeECZW0pzqUzv91i64g&oe=6A94C2C0",
|
||||||
|
"config_width": 240,
|
||||||
|
"config_height": 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s908x908_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQEWiGTJ46laGddrtiCzkuCEBzFQ3a06SjOzKQrkXYwaIw&oe=6A94C2C0",
|
||||||
|
"config_width": 908,
|
||||||
|
"config_height": 908
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s750x750_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQHKnmtJZvA6WEyyYlqUuQgWeuS7sJ6Wd-H1cu5txKuqCg&oe=6A94C2C0",
|
||||||
|
"config_width": 750,
|
||||||
|
"config_height": 750
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s640x640_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQE77HrYSi7SUo2wl1VIK6tG7G-utnKBVl0vy7MeMYpRhg&oe=6A94C2C0",
|
||||||
|
"config_width": 640,
|
||||||
|
"config_height": 640
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s480x480_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQGrmov3Fwc1Oi7m8EqRJ5A-myEZulSIvgXSLGD0v4IROA&oe=6A94C2C0",
|
||||||
|
"config_width": 480,
|
||||||
|
"config_height": 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s320x320_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQHPYSVKwrc30CByL0K0Xw47IeVGnsTGR3q_UrF0CQFYbw&oe=6A94C2C0",
|
||||||
|
"config_width": 320,
|
||||||
|
"config_height": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s240x240_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQGnjqVQPu3j63QJDYSWXJMZXebkUgLWQ9QP4c4TXLagFA&oe=6A94C2C0",
|
||||||
|
"config_width": 240,
|
||||||
|
"config_height": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786916068_18641156695049152_3191421164797370249_n.jpg?stp=c228.0.908.908a_dst-jpg_e35_s150x150_tt6&_nc_cat=1&ig_cache_key=Mzk3MTY2MDY2NDE4NTIwNjI2Mw%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0ueHBpZHMuMTM2NC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=zRh8DkLPtC4Q7kNvwE0J2PT&_nc_oc=AdrHTvAeTxpHkpmm0whoibvAJegYaNw_EhMU-Cs6VTBhNT6TFN2TozFb72dGaciL9D0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=zjcD5lC9EZKmMoawux3bfQ&_nc_ss=7b689&oh=00_AQFbLUCDVUG8zyDJ-d5IonUSnjfcp4VKGTAgE5m6DwYkUw&oe=6A94C2C0",
|
||||||
|
"config_width": 150,
|
||||||
|
"config_height": 150
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dimensions": {
|
||||||
|
"height": 908,
|
||||||
|
"width": 1364
|
||||||
|
},
|
||||||
|
"accessibility_caption": null,
|
||||||
|
"owner": {
|
||||||
|
"id": "528817151",
|
||||||
|
"username": "nasa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+171
@@ -0,0 +1,171 @@
|
|||||||
|
{
|
||||||
|
"__typename": "GraphVideo",
|
||||||
|
"id": "3966649126543481665",
|
||||||
|
"shortcode": "DcMXl1IPNtB",
|
||||||
|
"is_video": true,
|
||||||
|
"display_url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQGtRb_WA92yP0l-V0ro4YsQA-bGyUN5DItNF7jlvwjt8Q&oe=6A94BE70",
|
||||||
|
"display_resources": [
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQGtRb_WA92yP0l-V0ro4YsQA-bGyUN5DItNF7jlvwjt8Q&oe=6A94BE70",
|
||||||
|
"config_width": 720,
|
||||||
|
"config_height": 1279
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_p640x640_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQF_B1wyzLWDjE2AEe_Ln59SEUKMRmKGychjKJ8p8KjMgw&oe=6A94BE70",
|
||||||
|
"config_width": 640,
|
||||||
|
"config_height": 1137
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_p480x480_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQGwK-Jpk0mUN5CGJgu9JU5qlL5WSueQgHkzKYt4DNB-0Q&oe=6A94BE70",
|
||||||
|
"config_width": 480,
|
||||||
|
"config_height": 853
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_p320x320_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQGzej-eeUFGypb713VdlZMjywwWD9jgAOE-VMpJlbKxMQ&oe=6A94BE70",
|
||||||
|
"config_width": 320,
|
||||||
|
"config_height": 568
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_p240x240_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQE_1bCs21AKeE0npG8XZ-Ww-XmuxTsyqhUZ4iQT8OZCCw&oe=6A94BE70",
|
||||||
|
"config_width": 240,
|
||||||
|
"config_height": 426
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s720x720_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQEDmZcbt2-5NJbF5tz3wUIJLfUWgWXJeVq9JKkjPKCBSw&oe=6A94BE70",
|
||||||
|
"config_width": 720,
|
||||||
|
"config_height": 720
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s720x720_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQEDmZcbt2-5NJbF5tz3wUIJLfUWgWXJeVq9JKkjPKCBSw&oe=6A94BE70",
|
||||||
|
"config_width": 720,
|
||||||
|
"config_height": 720
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s640x640_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQECHQ5aBlrqrheCB9_wRPkGfBIAawi78-lBMZnEi0Kuww&oe=6A94BE70",
|
||||||
|
"config_width": 640,
|
||||||
|
"config_height": 640
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s480x480_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQE-zTuiqFevkF_GCW2lEURPyxHZEDSo8YdK5bXaC3c5DA&oe=6A94BE70",
|
||||||
|
"config_width": 480,
|
||||||
|
"config_height": 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s320x320_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQEQSI9UhJbnZrJQ6hMYksq7EkzXNb72ZZuKyN2v3ItYGA&oe=6A94BE70",
|
||||||
|
"config_width": 320,
|
||||||
|
"config_height": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s240x240_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQFEuDEO6DF1pjCF56aTGnLiXscVwCiJQ8W7Q7inmjlh7w&oe=6A94BE70",
|
||||||
|
"config_width": 240,
|
||||||
|
"config_height": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s150x150_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQFjCCRTThYa08iR2-q7k_T1Bkl2JRYtSAAPMzW4oxyrlA&oe=6A94BE70",
|
||||||
|
"config_width": 150,
|
||||||
|
"config_height": 150
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thumbnail_src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQGtRb_WA92yP0l-V0ro4YsQA-bGyUN5DItNF7jlvwjt8Q&oe=6A94BE70",
|
||||||
|
"thumbnail_resources": [
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQGtRb_WA92yP0l-V0ro4YsQA-bGyUN5DItNF7jlvwjt8Q&oe=6A94BE70",
|
||||||
|
"config_width": 720,
|
||||||
|
"config_height": 1279
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_p640x640_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQF_B1wyzLWDjE2AEe_Ln59SEUKMRmKGychjKJ8p8KjMgw&oe=6A94BE70",
|
||||||
|
"config_width": 640,
|
||||||
|
"config_height": 1137
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_p480x480_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQGwK-Jpk0mUN5CGJgu9JU5qlL5WSueQgHkzKYt4DNB-0Q&oe=6A94BE70",
|
||||||
|
"config_width": 480,
|
||||||
|
"config_height": 853
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_p320x320_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQGzej-eeUFGypb713VdlZMjywwWD9jgAOE-VMpJlbKxMQ&oe=6A94BE70",
|
||||||
|
"config_width": 320,
|
||||||
|
"config_height": 568
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=dst-jpg_e15_p240x240_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQE_1bCs21AKeE0npG8XZ-Ww-XmuxTsyqhUZ4iQT8OZCCw&oe=6A94BE70",
|
||||||
|
"config_width": 240,
|
||||||
|
"config_height": 426
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s720x720_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQEDmZcbt2-5NJbF5tz3wUIJLfUWgWXJeVq9JKkjPKCBSw&oe=6A94BE70",
|
||||||
|
"config_width": 720,
|
||||||
|
"config_height": 720
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s720x720_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQEDmZcbt2-5NJbF5tz3wUIJLfUWgWXJeVq9JKkjPKCBSw&oe=6A94BE70",
|
||||||
|
"config_width": 720,
|
||||||
|
"config_height": 720
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s640x640_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQECHQ5aBlrqrheCB9_wRPkGfBIAawi78-lBMZnEi0Kuww&oe=6A94BE70",
|
||||||
|
"config_width": 640,
|
||||||
|
"config_height": 640
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s480x480_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQE-zTuiqFevkF_GCW2lEURPyxHZEDSo8YdK5bXaC3c5DA&oe=6A94BE70",
|
||||||
|
"config_width": 480,
|
||||||
|
"config_height": 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s320x320_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQEQSI9UhJbnZrJQ6hMYksq7EkzXNb72ZZuKyN2v3ItYGA&oe=6A94BE70",
|
||||||
|
"config_width": 320,
|
||||||
|
"config_height": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s240x240_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQFEuDEO6DF1pjCF56aTGnLiXscVwCiJQ8W7Q7inmjlh7w&oe=6A94BE70",
|
||||||
|
"config_width": 240,
|
||||||
|
"config_height": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/779017700_18615481711026724_2930580986888954454_n.jpg?stp=c0.279.720.720a_dst-jpg_e15_s150x150_tt6&_nc_cat=102&ig_cache_key=Mzk2NjY0OTEyNjU0MzQ4MTY2NTE4NjE1NDgxNzA4MDI2NzI0.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=_zfUbhy2mvcQ7kNvwE20APB&_nc_oc=AdoDQKRgvw29MPtkZKIYbX3sEBc-La2h3_G25SXhYvWRVQ7rwReeFSXqY5A6VGMA1IQ&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&oh=00_AQFjCCRTThYa08iR2-q7k_T1Bkl2JRYtSAAPMzW4oxyrlA&oe=6A94BE70",
|
||||||
|
"config_width": 150,
|
||||||
|
"config_height": 150
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dimensions": {
|
||||||
|
"height": 1279,
|
||||||
|
"width": 720
|
||||||
|
},
|
||||||
|
"video_duration": 50.2,
|
||||||
|
"video_view_count": 475654,
|
||||||
|
"accessibility_caption": null,
|
||||||
|
"edge_media_to_caption": {
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"text": "Soothing spacewalk scenes. \n\nToday, August 18, NASA’s @astro_anil and ESA’s @soph_astro completed a 6 hour, 23 minute spacewalk to remove a failed communications antenna and stow it outside the International Space Station. These challenging tasks are accompanied by rewarding views as the orbiting laboratory continues soaring at 17,500 miles per hour over our blue marble below. The next spacewalk is scheduled for Tuesday, August 25."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"edge_media_to_comment": {
|
||||||
|
"count": 715
|
||||||
|
},
|
||||||
|
"edge_liked_by": {
|
||||||
|
"count": 85611
|
||||||
|
},
|
||||||
|
"product_type": "clips",
|
||||||
|
"clips_music_attribution_info": {
|
||||||
|
"artist_name": "nasajohnson",
|
||||||
|
"song_name": "Original audio",
|
||||||
|
"uses_original_audio": true,
|
||||||
|
"should_mute_audio": false
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"id": "673882723",
|
||||||
|
"username": "nasajohnson",
|
||||||
|
"is_verified": true,
|
||||||
|
"profile_pic_url": "https://scontent-lga3-3.cdninstagram.com/v/t51.2885-19/11421977_902607023152688_677352411_a.jpg?stp=dst-jpg_s100x100_tt6&_nc_cat=1&ccb=7-5&_nc_sid=bf7eb4&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLnd3dy4xNTAuQzMifQ%3D%3D&_nc_ohc=t7r7WfE9wi4Q7kNvwGng2Hr&_nc_oc=AdocMGZVl-s4HUDY0zv9Mbmg_E5g-kOHMBhXj3qaByepF_0StOc9xREfSzjjUXscR24&_nc_zt=24&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_ss=7b689&oh=00_AQHxnWL0OfUdhnX-dZOq384Hm1k4qOn6b-r2BKGcFnofYw&oe=6A94BBD0",
|
||||||
|
"edge_followed_by": {
|
||||||
|
"count": 1557224
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"video_url": "https://scontent-lga3-2.cdninstagram.com/o1/v/t2/f2/m86/AQPgq1-nGTOYE9d5beq3YaaVK3xuCnLyLp5UPN0uEmY20et9PXTRm8vYOV6vA-CxnblNFzQ8GBreVfjmWcVAbkxDjifOgGcxjchAG44.mp4?_nc_cat=105&_nc_sid=5e9851&_nc_ht=scontent-lga3-2.cdninstagram.com&_nc_ohc=nLSu1-JcH6AQ7kNvwGQN95X&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5JTlNUQUdSQU0uQ0xJUFMuQzMuNzIwLmRhc2hfYmFzZWxpbmVfMV92MSIsInhwdl9hc3NldF9pZCI6MTQ3NDM5OTQxNDcyMTIyMiwiYXNzZXRfYWdlX2RheXMiOjcsInZpX3VzZWNhc2VfaWQiOjEwMDk5LCJkdXJhdGlvbl9zIjo1MCwidXJsZ2VuX3NvdXJjZSI6Ind3dyJ9&ccb=17-1&vs=5b519008049cb832&_nc_vs=HBksFQIYUmlnX3hwdl9yZWVsc19wZXJtYW5lbnRfc3JfcHJvZC9GODQzODMxRkEyQzY2NjQyMTU4NERGREIxNzBDMEQ5Ql92aWRlb19kYXNoaW5pdC5tcDQVAALIARIAFQIYUWlnX3hwdl9wbGFjZW1lbnRfcGVybWFuZW50X3YyLzlFNDNDMkJEODlCMUQzQjIxRjNBMDZGRjM3REJBNEJBX2F1ZGlvX2Rhc2hpbml0Lm1wNBUCAsgBEgAoABgAGwKIB3VzZV9vaWwBMRJwcm9ncmVzc2l2ZV9yZWNpcGUBMRUAACaM6_jVq72eBRUCKAJDMywXQEkZmZmZmZoYEmRhc2hfYmFzZWxpbmVfMV92MREAdf4HZeadAQA&_nc_gid=EqT6ZGn0bzXJSy9O21isTg&_nc_ss=7b689&_nc_zt=28&oh=00_AQHP-GOLilVDL42cglVtfGDwtuQXDnHnXjsjqCB1SNi7_Q&oe=6A90D486"
|
||||||
|
}
|
||||||
Vendored
+224
@@ -0,0 +1,224 @@
|
|||||||
|
{
|
||||||
|
"pk": "3971780769513546778",
|
||||||
|
"id": "3971780769513546778_28829152388",
|
||||||
|
"logging_info_token": null,
|
||||||
|
"user": {
|
||||||
|
"pk": "28829152388",
|
||||||
|
"profile_pic_url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-19/669747085_18089786557960389_922072622069351626_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_cat=1&_nc_oc=Q6cZ2gHm2Iiju05wXtwnDWynLqWrPmzmmyfliW09scvzlkifUe8ABDrDhjiX_uvXo0YG7z8&_nc_ohc=SpvjJtRtnpgQ7kNvwF-UG7J&_nc_gid=rzGDpyil93AHRBGfhIuUZw&edm=APs17CUBAAAA&ccb=7-5&oh=00_AQEATjmxZW1Y5Lod5QCtgRUp9dj7xJT1_rP9cO0wNd_ZbQ&oe=6A94B9CF&_nc_sid=10d13b",
|
||||||
|
"friendship_status": null,
|
||||||
|
"username": "nasa",
|
||||||
|
"id": "28829152388",
|
||||||
|
"interop_messaging_user_fbid": "18089786566960389",
|
||||||
|
"is_meta_ai_bot": false,
|
||||||
|
"full_name": "NASA",
|
||||||
|
"transparency_label": null,
|
||||||
|
"transparency_product": null,
|
||||||
|
"transparency_product_enabled": false,
|
||||||
|
"is_verified": true,
|
||||||
|
"has_onboarded_to_text_post_app": true,
|
||||||
|
"text_post_app_is_private": false,
|
||||||
|
"platform_podcast_episode_info": null,
|
||||||
|
"platform_podcast_info": null
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"show_header_follow": false,
|
||||||
|
"id": "XDTTextPostAppMediaInfo:17943069714278397",
|
||||||
|
"is_ghost_post": false,
|
||||||
|
"self_thread_info": null,
|
||||||
|
"is_spoiler_media": false,
|
||||||
|
"is_markup": false,
|
||||||
|
"ghost_post_exp_time_ms": null,
|
||||||
|
"ghost_post_approximate_like_count_str": null,
|
||||||
|
"ghost_post_reply_type": null,
|
||||||
|
"ghost_post_approximate_reply_count_str": null,
|
||||||
|
"direct_reply_count": 528,
|
||||||
|
"repost_count": 9182,
|
||||||
|
"quote_count": 553,
|
||||||
|
"reshare_count": 3420,
|
||||||
|
"attachment_tombstone_info": null,
|
||||||
|
"custom_feed_preview_info": null,
|
||||||
|
"platform_podcast_episode_info": null,
|
||||||
|
"platform_podcast_info": null,
|
||||||
|
"link_preview_attachment": null,
|
||||||
|
"link_preview_response": null,
|
||||||
|
"linked_inline_media": null,
|
||||||
|
"snippet_attachment_info": null,
|
||||||
|
"game_score_share_info": null,
|
||||||
|
"public_view_count_card_attachment_info": null,
|
||||||
|
"special_effects_enabled_str": "",
|
||||||
|
"algo_tweaks_info": null,
|
||||||
|
"text_fragments": {
|
||||||
|
"fragments": [
|
||||||
|
{
|
||||||
|
"fragment_type": "plaintext",
|
||||||
|
"link_fragment": null,
|
||||||
|
"mention_fragment": null,
|
||||||
|
"plaintext": "Today, the universe carries a little more sparkle in honor of Dolly Parton.✨",
|
||||||
|
"inline_sticker_fragment": null,
|
||||||
|
"linkified_web_url": null,
|
||||||
|
"linkified_in_app_url": null,
|
||||||
|
"styling_info": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_reply": false,
|
||||||
|
"can_reply": false,
|
||||||
|
"gen_ai_reply_bot_on_media_deny_reason": "UNAVAILABLE",
|
||||||
|
"share_info": {
|
||||||
|
"quoted_attachment_author_attribution_allowed": true,
|
||||||
|
"quoted_attachment_post_unavailable": false,
|
||||||
|
"quoted_attachment_post": null,
|
||||||
|
"quoted_post": null,
|
||||||
|
"reposted_post": null
|
||||||
|
},
|
||||||
|
"reply_to_author": null,
|
||||||
|
"reply_approval_info": {
|
||||||
|
"pending_reply_status": null,
|
||||||
|
"pending_reply_count": null,
|
||||||
|
"ignored_reply_count": null
|
||||||
|
},
|
||||||
|
"hush_info": null,
|
||||||
|
"system_status_message": null,
|
||||||
|
"root_post_author": null,
|
||||||
|
"reply_control": "everyone",
|
||||||
|
"private_reply_partner": null,
|
||||||
|
"self_thread_count": 0,
|
||||||
|
"tag_header": null,
|
||||||
|
"is_liked_by_root_author": false,
|
||||||
|
"pinned_post_info": {
|
||||||
|
"is_pinned_to_parent_post": false,
|
||||||
|
"is_pinned_to_profile": false
|
||||||
|
},
|
||||||
|
"related_trends_info": null,
|
||||||
|
"is_post_unavailable": false,
|
||||||
|
"post_unavailable_reason": null,
|
||||||
|
"post_tombstone_info": null,
|
||||||
|
"show_podcast_link": false,
|
||||||
|
"fediverse_info": null,
|
||||||
|
"is_reply_approval_enabled": null,
|
||||||
|
"can_private_reply": false,
|
||||||
|
"impression_count": null
|
||||||
|
},
|
||||||
|
"is_paid_partnership": null,
|
||||||
|
"audio": null,
|
||||||
|
"caption": {
|
||||||
|
"text": "Today, the universe carries a little more sparkle in honor of Dolly Parton.✨",
|
||||||
|
"has_translation": null,
|
||||||
|
"pk": "17943069726278397",
|
||||||
|
"text_translation": null
|
||||||
|
},
|
||||||
|
"caption_is_edited": false,
|
||||||
|
"transcription_data": null,
|
||||||
|
"carousel_media": null,
|
||||||
|
"code": "DcemZEElSQa",
|
||||||
|
"image_versions2": {
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"height": 1188,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=dst-jpg_e35_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQEal79PmtoKTe32PNX8w2qKcMMscU9ocLOmO2EpKfPoWA&oe=6A94BD75",
|
||||||
|
"width": 1280
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 1002,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=dst-jpg_e35_s1080x1080_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQE_7ejupEsEqp952pikzpc7q1tFbIckEjamJyLMr-vnPA&oe=6A94BD75",
|
||||||
|
"width": 1080
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 668,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=dst-jpg_e35_s720x720_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQE7ShsMEeAOAyqUUDeFxiBHkPWVqsvNWDBzyCs7ELU8Fw&oe=6A94BD75",
|
||||||
|
"width": 720
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 594,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=dst-jpg_e35_s640x640_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQFGuzmtPsuDrQ-2krFu5b4efnG6BoV0qrJzlQFHZ5MxVg&oe=6A94BD75",
|
||||||
|
"width": 640
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 446,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=dst-jpg_e35_s480x480_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQGzmRKy9dlOGFKXODPu1NXzD_HdJmyXbFV1Jm9XqO7YYg&oe=6A94BD75",
|
||||||
|
"width": 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 297,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=dst-jpg_e35_s320x320_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQG0jgN9uJQzqYA-HdsQWuluQhWQ97uN-02SxoCA9XI7ZA&oe=6A94BD75",
|
||||||
|
"width": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 223,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=dst-jpg_e35_s240x240_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQFwsgoM_Nj7mYr5KmaJBPRXG_3pcTh6K8ggDDt5L5NlHw&oe=6A94BD75",
|
||||||
|
"width": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 1080,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=c46.0.1188.1188a_dst-jpg_e35_s1080x1080_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQHvbE3eyRGBlmsV2II4zQV2GkG-YC3SVd-1Z6yVdrHd8Q&oe=6A94BD75",
|
||||||
|
"width": 1080
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 750,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=c46.0.1188.1188a_dst-jpg_e35_s750x750_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQEi9pdW9Jop-EdmUIlA_5yEqFzV-frdu_oQJP7lzJaeGw&oe=6A94BD75",
|
||||||
|
"width": 750
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 640,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=c46.0.1188.1188a_dst-jpg_e35_s640x640_sh2.08_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQHzruEDwg5sO_cjAEU7Shiy8oOfTsDGo4s124B9QoD69g&oe=6A94BD75",
|
||||||
|
"width": 640
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 480,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=c46.0.1188.1188a_dst-jpg_e35_s480x480_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQHwU5sd7eI-JJhq1QRaMi0o9u3amuvNwP1vt7mnM5rwrw&oe=6A94BD75",
|
||||||
|
"width": 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 320,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=c46.0.1188.1188a_dst-jpg_e35_s320x320_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQEJY_DaVSibp6J1odOOlBO5nxyX1Pz3uoO3HDXLOJCz_w&oe=6A94BD75",
|
||||||
|
"width": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 240,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=c46.0.1188.1188a_dst-jpg_e35_s240x240_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQHWuBIEFSNVcbmVBZnUd5nPnfHmejqTGPnO5VfTckdRoA&oe=6A94BD75",
|
||||||
|
"width": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 150,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786827876_18116118340960389_3811383027336802397_n.jpg?stp=c46.0.1188.1188a_dst-jpg_e35_s150x150_tt6&_nc_cat=1&ig_cache_key=Mzk3MTc4MDc2OTUxMzU0Njc3OA%3D%3D.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkZFRUQueHBpZHMuMTI4MC5zZHIucmVndWxhcl9waG90by5DMyJ9&_nc_ohc=eyI3rc73PpMQ7kNvwH3uWDZ&_nc_oc=AdqiC2eeFMSzAnhc4mxdGpTrvG705GdjqEEiREa56QlIcqfCS_y2MJZKPRy5yth4-Fc&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_gid=rzGDpyil93AHRBGfhIuUZw&_nc_ss=7a22e&oh=00_AQE0M8C8iH0byNUh2Vkm4snE5arNmSzmVVg5HHOXAs0sbA&oe=6A94BD75",
|
||||||
|
"width": 150
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"original_height": 1188,
|
||||||
|
"original_width": 1280,
|
||||||
|
"accessibility_caption": "A dense globular star cluster glittering with thousands of stars in white, blue, orange, and red, brightest at its packed center and fading outward into the black of space. Image Credit: NASA, ESA, G. Piotto (Universita degli Studi di Padova); Image Processing: Gladys Kober (NASA/Catholic University of America)",
|
||||||
|
"usertags": null,
|
||||||
|
"is_dash_eligible": null,
|
||||||
|
"number_of_qualities": null,
|
||||||
|
"video_dash_manifest": null,
|
||||||
|
"video_versions": null,
|
||||||
|
"has_audio": null,
|
||||||
|
"media_type": 1,
|
||||||
|
"caption_add_on": null,
|
||||||
|
"text_app_music_info": null,
|
||||||
|
"fbid": "17943069714278397",
|
||||||
|
"like_count": 122392,
|
||||||
|
"has_viewer_saved": null,
|
||||||
|
"giphy_media_info": null,
|
||||||
|
"text_app_glimmer_post_info": null,
|
||||||
|
"original_lang_for_translations": null,
|
||||||
|
"detected_language": null,
|
||||||
|
"media_overlay_info": null,
|
||||||
|
"metaPlace": null,
|
||||||
|
"meta_place": null,
|
||||||
|
"gen_ai_detection_method": {
|
||||||
|
"detection_method": "NONE"
|
||||||
|
},
|
||||||
|
"audience": null,
|
||||||
|
"taken_at": 1787693185,
|
||||||
|
"organic_tracking_token": "eyJ2ZXJzaW9uIjo1LCJwYXlsb2FkIjp7ImlzX2FuYWx5dGljc190cmFja2VkIjpmYWxzZSwidXVpZCI6IjZlZmI4NjQ5LWMyNmYtNDFmYi1hZDFhLTQzNzQ4YTU4OTI3MjM5NzE3ODA3Njk1MTM1NDY3NzgifSwic2lnbmF0dXJlIjoiIn0=",
|
||||||
|
"__token": null,
|
||||||
|
"triage_comments": null,
|
||||||
|
"canonical_url": null,
|
||||||
|
"seo_ocr_for_related_post": null,
|
||||||
|
"seo_transcript_for_related_post": null,
|
||||||
|
"like_and_view_counts_disabled": false,
|
||||||
|
"is_fb_only": null,
|
||||||
|
"is_internal_only": null
|
||||||
|
}
|
||||||
Vendored
+256
@@ -0,0 +1,256 @@
|
|||||||
|
{
|
||||||
|
"pk": "3971077570246569860",
|
||||||
|
"id": "3971077570246569860_28829152388",
|
||||||
|
"logging_info_token": null,
|
||||||
|
"user": {
|
||||||
|
"pk": "28829152388",
|
||||||
|
"profile_pic_url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-19/669747085_18089786557960389_922072622069351626_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=scontent-lga3-3.cdninstagram.com&_nc_cat=1&_nc_oc=Q6cZ2gFIFNnHMIRY_vd0KImDc0ycTGoY25RbEV4P-ZOWuKecfdXbpqvVbYWSc9J54pmYfy8&_nc_ohc=SpvjJtRtnpgQ7kNvwF-UG7J&_nc_gid=GQtesiuN1WsouWGt-0UNcg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AQGTqVT94utqlzSDVpaPWBdzPzwzyYyojGO1fH7ZX2Jz0w&oe=6A94B9CF&_nc_sid=10d13b",
|
||||||
|
"friendship_status": null,
|
||||||
|
"username": "nasa",
|
||||||
|
"id": "28829152388",
|
||||||
|
"interop_messaging_user_fbid": "18089786566960389",
|
||||||
|
"is_meta_ai_bot": false,
|
||||||
|
"full_name": "NASA",
|
||||||
|
"transparency_label": null,
|
||||||
|
"transparency_product": null,
|
||||||
|
"transparency_product_enabled": false,
|
||||||
|
"is_verified": true,
|
||||||
|
"has_onboarded_to_text_post_app": true,
|
||||||
|
"text_post_app_is_private": false,
|
||||||
|
"platform_podcast_episode_info": null,
|
||||||
|
"platform_podcast_info": null
|
||||||
|
},
|
||||||
|
"text_post_app_info": {
|
||||||
|
"show_header_follow": false,
|
||||||
|
"id": "XDTTextPostAppMediaInfo:18093251546437808",
|
||||||
|
"is_ghost_post": false,
|
||||||
|
"self_thread_info": null,
|
||||||
|
"is_spoiler_media": false,
|
||||||
|
"is_markup": false,
|
||||||
|
"ghost_post_exp_time_ms": null,
|
||||||
|
"ghost_post_approximate_like_count_str": null,
|
||||||
|
"ghost_post_reply_type": null,
|
||||||
|
"ghost_post_approximate_reply_count_str": null,
|
||||||
|
"direct_reply_count": 17,
|
||||||
|
"repost_count": 32,
|
||||||
|
"quote_count": 6,
|
||||||
|
"reshare_count": null,
|
||||||
|
"attachment_tombstone_info": null,
|
||||||
|
"custom_feed_preview_info": null,
|
||||||
|
"platform_podcast_episode_info": null,
|
||||||
|
"platform_podcast_info": null,
|
||||||
|
"link_preview_attachment": null,
|
||||||
|
"link_preview_response": null,
|
||||||
|
"linked_inline_media": null,
|
||||||
|
"snippet_attachment_info": null,
|
||||||
|
"game_score_share_info": null,
|
||||||
|
"public_view_count_card_attachment_info": null,
|
||||||
|
"special_effects_enabled_str": "",
|
||||||
|
"algo_tweaks_info": null,
|
||||||
|
"text_fragments": {
|
||||||
|
"fragments": [
|
||||||
|
{
|
||||||
|
"fragment_type": "plaintext",
|
||||||
|
"link_fragment": null,
|
||||||
|
"mention_fragment": null,
|
||||||
|
"plaintext": "Home improvement looks a little different at 250 miles (400 km) above Earth.\n\nWatch live as NASA astronaut Anil Menon and ",
|
||||||
|
"inline_sticker_fragment": null,
|
||||||
|
"linkified_web_url": null,
|
||||||
|
"linkified_in_app_url": null,
|
||||||
|
"styling_info": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fragment_type": "mention",
|
||||||
|
"link_fragment": null,
|
||||||
|
"mention_fragment": {
|
||||||
|
"mentioned_user": {
|
||||||
|
"username": "europeanspaceagency",
|
||||||
|
"profile_pic_url": "https://scontent-lga3-1.cdninstagram.com/v/t51.82787-19/655065000_17954378349102382_3323192270181181889_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=scontent-lga3-1.cdninstagram.com&_nc_cat=110&_nc_oc=Q6cZ2gFIFNnHMIRY_vd0KImDc0ycTGoY25RbEV4P-ZOWuKecfdXbpqvVbYWSc9J54pmYfy8&_nc_ohc=GaiFH9ff8vMQ7kNvwHTNFqh&_nc_gid=GQtesiuN1WsouWGt-0UNcg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AQGYzvl9jF6JvEBcGVcLCRwfzZidH4j8IsKeh3H9b60VIQ&oe=6A94BC77&_nc_sid=10d13b",
|
||||||
|
"id": "63029318381"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plaintext": "@europeanspaceagency",
|
||||||
|
"inline_sticker_fragment": null,
|
||||||
|
"linkified_web_url": null,
|
||||||
|
"linkified_in_app_url": null,
|
||||||
|
"styling_info": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fragment_type": "plaintext",
|
||||||
|
"link_fragment": null,
|
||||||
|
"mention_fragment": null,
|
||||||
|
"plaintext": " 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.",
|
||||||
|
"inline_sticker_fragment": null,
|
||||||
|
"linkified_web_url": null,
|
||||||
|
"linkified_in_app_url": null,
|
||||||
|
"styling_info": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_reply": false,
|
||||||
|
"can_reply": false,
|
||||||
|
"gen_ai_reply_bot_on_media_deny_reason": "UNAVAILABLE",
|
||||||
|
"share_info": {
|
||||||
|
"quoted_attachment_author_attribution_allowed": true,
|
||||||
|
"quoted_attachment_post_unavailable": false,
|
||||||
|
"quoted_attachment_post": null,
|
||||||
|
"quoted_post": null,
|
||||||
|
"reposted_post": null
|
||||||
|
},
|
||||||
|
"reply_to_author": null,
|
||||||
|
"reply_approval_info": {
|
||||||
|
"pending_reply_status": null,
|
||||||
|
"pending_reply_count": null,
|
||||||
|
"ignored_reply_count": null
|
||||||
|
},
|
||||||
|
"hush_info": null,
|
||||||
|
"system_status_message": null,
|
||||||
|
"root_post_author": null,
|
||||||
|
"reply_control": "everyone",
|
||||||
|
"private_reply_partner": null,
|
||||||
|
"self_thread_count": 0,
|
||||||
|
"tag_header": {
|
||||||
|
"display_name": "Space Threads",
|
||||||
|
"id": "18401017042011621",
|
||||||
|
"is_community": false,
|
||||||
|
"tag_cluster_name": "Space Threads",
|
||||||
|
"community_emoji": null
|
||||||
|
},
|
||||||
|
"is_liked_by_root_author": false,
|
||||||
|
"pinned_post_info": {
|
||||||
|
"is_pinned_to_parent_post": false,
|
||||||
|
"is_pinned_to_profile": false
|
||||||
|
},
|
||||||
|
"related_trends_info": null,
|
||||||
|
"is_post_unavailable": false,
|
||||||
|
"post_unavailable_reason": null,
|
||||||
|
"post_tombstone_info": null,
|
||||||
|
"show_podcast_link": false,
|
||||||
|
"fediverse_info": null,
|
||||||
|
"is_reply_approval_enabled": null,
|
||||||
|
"can_private_reply": false,
|
||||||
|
"impression_count": null
|
||||||
|
},
|
||||||
|
"is_paid_partnership": null,
|
||||||
|
"audio": null,
|
||||||
|
"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.",
|
||||||
|
"has_translation": null,
|
||||||
|
"pk": "18093251549437808",
|
||||||
|
"text_translation": null
|
||||||
|
},
|
||||||
|
"caption_is_edited": false,
|
||||||
|
"transcription_data": null,
|
||||||
|
"carousel_media": null,
|
||||||
|
"code": "DccGgKuEW-E",
|
||||||
|
"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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQGUFSFmrI6MfZv8oIoU2U_OajISgstFYOvHRI8_Z5SVpg&oe=6A94A6BD",
|
||||||
|
"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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQEA1xxt_gdRpkz2pDvwlr8u5zRdYj33Goi8YwpwzSmizw&oe=6A94A6BD",
|
||||||
|
"width": 1080
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 900,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=dst-jpg_e35_p720x720_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQGbK_xH-Kc8fk3V-KeJM47rAi7vhOXfwbDZJoY7XdRh9Q&oe=6A94A6BD",
|
||||||
|
"width": 720
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 800,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=dst-jpg_e35_p640x640_sh2.08_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQEuYtcxoD7U9PttpSzElUYLsG8NUClaHTUre7GYAqPZXw&oe=6A94A6BD",
|
||||||
|
"width": 640
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 600,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=dst-jpg_e35_p480x480_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQHy3W8CIAP-satvbr0K27RiGgWBZgEXfTZJS_enuy5zCA&oe=6A94A6BD",
|
||||||
|
"width": 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 400,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=dst-jpg_e35_p320x320_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQH--2hJKyukBLE4JpLi3RO3aSog4SxiyVACMd3GYOINaw&oe=6A94A6BD",
|
||||||
|
"width": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 300,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=dst-jpg_e35_p240x240_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQGkD9msAnIyuH71P-KMhxGBAzDc1ZDV1UmjgYMx75AOjQ&oe=6A94A6BD",
|
||||||
|
"width": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 1080,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=c0.200.1601.1601a_dst-jpg_e35_s1080x1080_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQGJsOyDMqmnajOn4uDRF2X25RbtzcVDtwh7j5geVYeoXA&oe=6A94A6BD",
|
||||||
|
"width": 1080
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 750,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=c0.200.1601.1601a_dst-jpg_e35_s750x750_sh2.08_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQEVTVHgzTkVZcl5f5cOY8S0oQORTaUElWei0HUFeud81A&oe=6A94A6BD",
|
||||||
|
"width": 750
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 640,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=c0.200.1601.1601a_dst-jpg_e35_s640x640_sh2.08_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQGImofSXjl9fD6WvyRqoAUulJcZWQvVoZ6Ry4Tr6QTQrQ&oe=6A94A6BD",
|
||||||
|
"width": 640
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 480,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=c0.200.1601.1601a_dst-jpg_e35_s480x480_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQGCstGELTo3jwQlQlfskYotCxLzZm6Du3R6cnMEdUkC0A&oe=6A94A6BD",
|
||||||
|
"width": 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 320,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=c0.200.1601.1601a_dst-jpg_e35_s320x320_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQGlw8Z5VkyB-5_GKe5RAWxgFMOZzpRp1PXnlKufMbJ7lw&oe=6A94A6BD",
|
||||||
|
"width": 320
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 240,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=c0.200.1601.1601a_dst-jpg_e35_s240x240_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQF91tfWUSAr0N_ug0u2Oy6z5MpUW3j6gkKzz57QTvnEXA&oe=6A94A6BD",
|
||||||
|
"width": 240
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"height": 150,
|
||||||
|
"url": "https://scontent-lga3-3.cdninstagram.com/v/t51.82787-15/786213247_18115965718960389_483590576803339480_n.jpg?stp=c0.200.1601.1601a_dst-jpg_e35_s150x150_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=GQtesiuN1WsouWGt-0UNcg&_nc_ss=7a22e&oh=00_AQFX3qXkuxkeFKghWanA0uSG_TyUn6X1KM38nG6S3Qp7Zg&oe=6A94A6BD",
|
||||||
|
"width": 150
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"original_height": 2001,
|
||||||
|
"original_width": 1601,
|
||||||
|
"accessibility_caption": null,
|
||||||
|
"usertags": null,
|
||||||
|
"is_dash_eligible": null,
|
||||||
|
"number_of_qualities": null,
|
||||||
|
"video_dash_manifest": null,
|
||||||
|
"video_versions": null,
|
||||||
|
"has_audio": null,
|
||||||
|
"media_type": 1,
|
||||||
|
"caption_add_on": null,
|
||||||
|
"text_app_music_info": null,
|
||||||
|
"fbid": "18093251546437808",
|
||||||
|
"like_count": 718,
|
||||||
|
"has_viewer_saved": null,
|
||||||
|
"giphy_media_info": null,
|
||||||
|
"text_app_glimmer_post_info": null,
|
||||||
|
"original_lang_for_translations": null,
|
||||||
|
"detected_language": null,
|
||||||
|
"media_overlay_info": null,
|
||||||
|
"metaPlace": null,
|
||||||
|
"meta_place": null,
|
||||||
|
"gen_ai_detection_method": {
|
||||||
|
"detection_method": "NONE"
|
||||||
|
},
|
||||||
|
"audience": null,
|
||||||
|
"taken_at": 1787609357,
|
||||||
|
"organic_tracking_token": "eyJ2ZXJzaW9uIjo1LCJwYXlsb2FkIjp7ImlzX2FuYWx5dGljc190cmFja2VkIjpmYWxzZSwidXVpZCI6ImU4YTU2ZDU1LTQxYjgtNDY0ZC04YzViLTY3N2E0YjAzY2U5OTM5NzEwNzc1NzAyNDY1Njk4NjAifSwic2lnbmF0dXJlIjoiIn0=",
|
||||||
|
"__token": null,
|
||||||
|
"triage_comments": null,
|
||||||
|
"canonical_url": null,
|
||||||
|
"seo_ocr_for_related_post": null,
|
||||||
|
"seo_transcript_for_related_post": null,
|
||||||
|
"like_and_view_counts_disabled": false,
|
||||||
|
"is_fb_only": null,
|
||||||
|
"is_internal_only": null
|
||||||
|
}
|
||||||
Vendored
+474
@@ -0,0 +1,474 @@
|
|||||||
|
{
|
||||||
|
"id": "7676209593449581855",
|
||||||
|
"desc": "this song is from hotel Transylvania 3 and it gets me everytime….#hoteltransylvania #music @joejonas ",
|
||||||
|
"createTime": "1787256830",
|
||||||
|
"scheduleTime": 0,
|
||||||
|
"video": {
|
||||||
|
"id": "7676209593449581855",
|
||||||
|
"height": 720,
|
||||||
|
"width": 900,
|
||||||
|
"duration": 24,
|
||||||
|
"ratio": "720p",
|
||||||
|
"cover": "https://p19-common-sign.tiktokcdn.com/tos-useast8-p-0068-tx2/ogf2D4AO1qUJIAiCYHRiCEYBBsMvhIRHfzVABE~tplv-tiktokx-origin.image?dr=14575&x-expires=1787922000&x-signature=r9wKrKOF3nVscg%2BRKuUi5O56OAI%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=my",
|
||||||
|
"originCover": "https://p16-common-sign.tiktokcdn.com/tos-useast8-p-0068-tx2/okfZY12VhBEiHbvIAIRqzPAC4CfAAqIUBERHii~tplv-tiktokx-origin.image?dr=14575&x-expires=1787922000&x-signature=%2Bj%2Fi7me4COihFVV%2Fj3EFSDdCzyM%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=my",
|
||||||
|
"dynamicCover": "https://p16-common-sign.tiktokcdn.com/tos-useast8-p-0068-tx2/o8f2d4A61qUlIAiAYHRiCExBBMsvhIRHfzVAAE~tplv-tiktokx-origin.image?dr=14575&x-expires=1787922000&x-signature=uPk4dWrnUiDRsyiYUeFD2wEMHzc%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=my",
|
||||||
|
"playAddr": "https://v16-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/o8lUEHfBVIzQRRqECYsvqh41HBAARif2iQB2EA/?a=1988&bti=ODszNWYuMDE6&&bt=616&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=NWZoO2Y7Z2U1Zjw8Nmg1ZUBpanJ3OG05cnVvZDMzaTczNEBiYi9hYTFiXmAxXzRfNjAuYSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=1bcd8512dd8c6dfd4afb7588d87015d5&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"downloadAddr": "https://v16-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/oAIbfMgeQIidlfDQXwqROGBFHIbQUHXeAAJLsG/?a=1988&bti=ODszNWYuMDE6&&bt=560&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=MzppPDU6PGQ2NDlpaTxpZ0BpanJ3OG05cnVvZDMzaTczNEA0LV8zXzYzXjUxNi9jLTNhYSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=51ff15c4396b1497404e4fceed38f030&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"shareCover": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"reflowCover": "",
|
||||||
|
"bitrate": 631183,
|
||||||
|
"encodedType": "normal",
|
||||||
|
"format": "mp4",
|
||||||
|
"videoQuality": "normal",
|
||||||
|
"encodeUserTag": "",
|
||||||
|
"codecType": "h264",
|
||||||
|
"definition": "720p",
|
||||||
|
"subtitleInfos": [],
|
||||||
|
"zoomCover": {
|
||||||
|
"240": "https://p16-common-sign.tiktokcdn.com/tos-useast8-p-0068-tx2/ogf2D4AO1qUJIAiCYHRiCEYBBsMvhIRHfzVABE~tplv-photomode-zoomcover:240:240.avif?dr=14555&x-expires=1787922000&x-signature=raFc0I6cEY0aV3LyuANZyENhez4%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=my&ftpl=1",
|
||||||
|
"480": "https://p16-common-sign.tiktokcdn.com/tos-useast8-p-0068-tx2/ogf2D4AO1qUJIAiCYHRiCEYBBsMvhIRHfzVABE~tplv-photomode-zoomcover:480:480.avif?dr=14555&x-expires=1787922000&x-signature=CfvYTajHhbuWboEoVp1wNSSm9Do%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=my&ftpl=1",
|
||||||
|
"720": "https://p16-common-sign.tiktokcdn.com/tos-useast8-p-0068-tx2/ogf2D4AO1qUJIAiCYHRiCEYBBsMvhIRHfzVABE~tplv-photomode-zoomcover:720:720.avif?dr=14555&x-expires=1787922000&x-signature=ztAkBkjfiPAHWbS44xaScOqIQxA%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=my&ftpl=1",
|
||||||
|
"960": "https://p16-common-sign.tiktokcdn.com/tos-useast8-p-0068-tx2/ogf2D4AO1qUJIAiCYHRiCEYBBsMvhIRHfzVABE~tplv-photomode-zoomcover:960:960.avif?dr=14555&x-expires=1787922000&x-signature=02y%2BXKEH8i9KxCJl5OkxVO%2FPpRs%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=my&ftpl=1"
|
||||||
|
},
|
||||||
|
"volumeInfo": {
|
||||||
|
"Loudness": -25.4,
|
||||||
|
"Peak": 0.29512
|
||||||
|
},
|
||||||
|
"bitrateInfo": [
|
||||||
|
{
|
||||||
|
"Bitrate": 631183,
|
||||||
|
"QualityType": 10,
|
||||||
|
"BitrateFPS": 25,
|
||||||
|
"GearName": "normal_720_0",
|
||||||
|
"PlayAddr": {
|
||||||
|
"DataSize": "1943651",
|
||||||
|
"Width": 900,
|
||||||
|
"Height": 720,
|
||||||
|
"Uri": "v15044gf0000da3lvrfog65j150t3ong",
|
||||||
|
"UrlList": [
|
||||||
|
"https://v16-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/o8lUEHfBVIzQRRqECYsvqh41HBAARif2iQB2EA/?a=1988&bti=ODszNWYuMDE6&&bt=616&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=NWZoO2Y7Z2U1Zjw8Nmg1ZUBpanJ3OG05cnVvZDMzaTczNEBiYi9hYTFiXmAxXzRfNjAuYSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=1bcd8512dd8c6dfd4afb7588d87015d5&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"https://v19-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/o8lUEHfBVIzQRRqECYsvqh41HBAARif2iQB2EA/?a=1988&bti=ODszNWYuMDE6&&bt=616&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=NWZoO2Y7Z2U1Zjw8Nmg1ZUBpanJ3OG05cnVvZDMzaTczNEBiYi9hYTFiXmAxXzRfNjAuYSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=1bcd8512dd8c6dfd4afb7588d87015d5&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=1b329eb379b94cc2abed48dcd82eece2&is_play_url=1&item_id=7676209593449581855&line=0&ply_type=2&pt=dGxiLmFnZW50LnRpa3Rva19sYl9hbGlzZ192MyYvQHBlYWtraWRzb25ncy92aWRlby83Njc2MjA5NTkzNDQ5NTgxODU1JjIwMjYwODI2MjE0NzE1Q0IzMTc2QzhFNzg3M0UwNkQ2RDkmMTc4Nzc1MjAzNSY4ZjNjM2ZmNzExYWI&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjY2NGNiZDRlN2MwYTFhYzgyMTAwNjE5MjQ1NmZlOThk&tk=tt_chain_token&video_id=v15044gf0000da3lvrfog65j150t3ong"
|
||||||
|
],
|
||||||
|
"UrlKey": "v15044gf0000da3lvrfog65j150t3ong_h264_720p_631183",
|
||||||
|
"FileHash": "c7390a70616856b596a7287a8c71c23f",
|
||||||
|
"FileCs": "c:0-21152-6062"
|
||||||
|
},
|
||||||
|
"CodecType": "h264",
|
||||||
|
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 86.079, \"v960\": 89.297, \"v864\": 90.686, \"v720\": 93.438}, \"ori\": {\"v1080\": 86.079, \"v960\": 89.297, \"v864\": 90.686, \"v720\": 93.438}}}",
|
||||||
|
"Format": "mp4",
|
||||||
|
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 100807}, {\\\"time\\\": 2, \\\"offset\\\": 168539}, {\\\"time\\\": 3, \\\"offset\\\": 251652}, {\\\"time\\\": 4, \\\"offset\\\": 323891}, {\\\"time\\\": 5, \\\"offset\\\": 422735}, {\\\"time\\\": 10, \\\"offset\\\": 864740}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 86.079, \\\"v960\\\": 89.297, \\\"v864\\\": 90.686, \\\"v720\\\": 93.438}, \\\"ori\\\": {\\\"v1080\\\": 86.079, \\\"v960\\\": 89.297, \\\"v864\\\": 90.686, \\\"v720\\\": 93.438}}}\",\"ufq\":\"\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"65b88c41c0963253ad31b6f68981a242\",\"dec_info\":\"\",\"gearvqm\":\"\",\"audio_bit_rate\":64439}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Bitrate": 454121,
|
||||||
|
"QualityType": 2,
|
||||||
|
"BitrateFPS": 30,
|
||||||
|
"GearName": "adapt_lowest_1080_1",
|
||||||
|
"PlayAddr": {
|
||||||
|
"DataSize": "1398409",
|
||||||
|
"Width": 1328,
|
||||||
|
"Height": 1062,
|
||||||
|
"Uri": "v15044gf0000da3lvrfog65j150t3ong",
|
||||||
|
"UrlList": [
|
||||||
|
"https://v16-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/oYVQ1EB2HzqAvs7kUmi4BihEREoBCAfVHIfWR4/?a=1988&bti=ODszNWYuMDE6&&bt=443&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=Njw6ZDo3PGhmNzo6ZGk6ZUBpanJ3OG05cnVvZDMzaTczNEAuYi8tNTQwXzQxMDFeMDIvYSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=385178b18687fe9afefb42d6f5a3eb7a&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"https://v19-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/oYVQ1EB2HzqAvs7kUmi4BihEREoBCAfVHIfWR4/?a=1988&bti=ODszNWYuMDE6&&bt=443&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=Njw6ZDo3PGhmNzo6ZGk6ZUBpanJ3OG05cnVvZDMzaTczNEAuYi8tNTQwXzQxMDFeMDIvYSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=385178b18687fe9afefb42d6f5a3eb7a&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=2b573faa4737447cbe3974870a27e913&is_play_url=1&item_id=7676209593449581855&line=0&ply_type=2&pt=dGxiLmFnZW50LnRpa3Rva19sYl9hbGlzZ192MyYvQHBlYWtraWRzb25ncy92aWRlby83Njc2MjA5NTkzNDQ5NTgxODU1JjIwMjYwODI2MjE0NzE1Q0IzMTc2QzhFNzg3M0UwNkQ2RDkmMTc4Nzc1MjAzNSY4ZjNjM2ZmNzExYWI&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjNlMDE2MTdjYTNlZTc4YWM0NzM3Yzc3MzY5MmI3ZmRi&tk=tt_chain_token&video_id=v15044gf0000da3lvrfog65j150t3ong"
|
||||||
|
],
|
||||||
|
"UrlKey": "v15044gf0000da3lvrfog65j150t3ong_bytevc1_1080p_454121",
|
||||||
|
"FileHash": "d8cc8aadbfe7a57f1c1e276351cd488f",
|
||||||
|
"FileCs": "c:0-22899-848b"
|
||||||
|
},
|
||||||
|
"CodecType": "h265_hvc1",
|
||||||
|
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 99.422, \"v960\": 99.602, \"v864\": 99.785, \"v720\": 99.588}, \"ori\": {\"v1080\": 95.634, \"v960\": 95.992, \"v864\": 96.434, \"v720\": 96.774}}}",
|
||||||
|
"Format": "mp4",
|
||||||
|
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 99301}, {\\\"time\\\": 2, \\\"offset\\\": 149436}, {\\\"time\\\": 3, \\\"offset\\\": 216926}, {\\\"time\\\": 4, \\\"offset\\\": 291090}, {\\\"time\\\": 5, \\\"offset\\\": 342297}, {\\\"time\\\": 10, \\\"offset\\\": 700192}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 99.422, \\\"v960\\\": 99.602, \\\"v864\\\": 99.785, \\\"v720\\\": 99.588}, \\\"ori\\\": {\\\"v1080\\\": 95.634, \\\"v960\\\": 95.992, \\\"v864\\\": 96.434, \\\"v720\\\": 96.774}}}\",\"ufq\":\"{\\\"enh\\\":68.246,\\\"playback\\\":{\\\"ori\\\":67.373,\\\"srv1\\\":71.161},\\\"src\\\":68.246,\\\"trans\\\":67.373,\\\"version\\\":\\\"v1.2\\\"}\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"e91245375450b02a8794eaf362ea32ee\",\"dec_info\":\"\",\"gearvqm\":\"\",\"audio_bit_rate\":64439,\"ps_info\":\"{\\\"vps\\\": \\\"AQwC//8BYAAAAwAAAwAAAwAAAwCWAACIJElgSA==\\\", \\\"sps\\\": \\\"AQMBYAAAAwAAAwAAAwAAAwCWAACgApiAEKfWWIJElySUQTPCEREREREYR8xPOX+/+v82fy/+v8yh+S/3/1/mz+X/1/jOEAg5qAgICCAAAAMAIAAAB4E=\\\", \\\"pps\\\": \\\"AcElPAiQ\\\"}\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Bitrate": 367742,
|
||||||
|
"QualityType": 14,
|
||||||
|
"BitrateFPS": 25,
|
||||||
|
"GearName": "adapt_lower_720_1",
|
||||||
|
"PlayAddr": {
|
||||||
|
"DataSize": "1132416",
|
||||||
|
"Width": 900,
|
||||||
|
"Height": 720,
|
||||||
|
"Uri": "v15044gf0000da3lvrfog65j150t3ong",
|
||||||
|
"UrlList": [
|
||||||
|
"https://v16-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/oQBUFizQfEYh2iPE4VdfGH0SrRBvBRACHqI1AE/?a=1988&bti=ODszNWYuMDE6&&bt=359&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=NGQ8OGQ7OjxnZDtnZmc6ZkBpanJ3OG05cnVvZDMzaTczNEAzL18xXzQwNmIxLzM0Li01YSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=285bfb2c419f07eaa51702e964d59000&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"https://v19-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/oQBUFizQfEYh2iPE4VdfGH0SrRBvBRACHqI1AE/?a=1988&bti=ODszNWYuMDE6&&bt=359&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=NGQ8OGQ7OjxnZDtnZmc6ZkBpanJ3OG05cnVvZDMzaTczNEAzL18xXzQwNmIxLzM0Li01YSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=285bfb2c419f07eaa51702e964d59000&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=8c071d7c6d284aed993778ba45b92a61&is_play_url=1&item_id=7676209593449581855&line=0&ply_type=2&pt=dGxiLmFnZW50LnRpa3Rva19sYl9hbGlzZ192MyYvQHBlYWtraWRzb25ncy92aWRlby83Njc2MjA5NTkzNDQ5NTgxODU1JjIwMjYwODI2MjE0NzE1Q0IzMTc2QzhFNzg3M0UwNkQ2RDkmMTc4Nzc1MjAzNSY4ZjNjM2ZmNzExYWI&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjgyODJiMGEwYWM5N2ZmOTIwMTlkNjIwZmU4OGFlYjcz&tk=tt_chain_token&video_id=v15044gf0000da3lvrfog65j150t3ong"
|
||||||
|
],
|
||||||
|
"UrlKey": "v15044gf0000da3lvrfog65j150t3ong_bytevc1_720p_367742",
|
||||||
|
"FileHash": "cb618674e450dec34b4ca1072f45da10",
|
||||||
|
"FileCs": "c:0-21304-df17"
|
||||||
|
},
|
||||||
|
"CodecType": "h265_hvc1",
|
||||||
|
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 97.147, \"v960\": 98.018, \"v864\": 98.584, \"v720\": 99.276}, \"ori\": {\"v1080\": 88.936, \"v960\": 91.796, \"v864\": 93.24, \"v720\": 95.889}}}",
|
||||||
|
"Format": "mp4",
|
||||||
|
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 79641}, {\\\"time\\\": 2, \\\"offset\\\": 114310}, {\\\"time\\\": 3, \\\"offset\\\": 165723}, {\\\"time\\\": 4, \\\"offset\\\": 213912}, {\\\"time\\\": 5, \\\"offset\\\": 268856}, {\\\"time\\\": 10, \\\"offset\\\": 538496}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 97.147, \\\"v960\\\": 98.018, \\\"v864\\\": 98.584, \\\"v720\\\": 99.276}, \\\"ori\\\": {\\\"v1080\\\": 88.936, \\\"v960\\\": 91.796, \\\"v864\\\": 93.24, \\\"v720\\\": 95.889}}}\",\"ufq\":\"{\\\"enh\\\":68.246,\\\"playback\\\":{\\\"ori\\\":61.182,\\\"srv1\\\":69.393},\\\"src\\\":68.246,\\\"trans\\\":61.182,\\\"version\\\":\\\"v1.2\\\"}\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"e91245375450b02a8794eaf362ea32ee\",\"dec_info\":\"\",\"gearvqm\":\"{\\\"v3.0\\\": {\\\"ori\\\": 98.449, \\\"sr20\\\": 104.821}}\",\"audio_bit_rate\":64439,\"ps_info\":\"{\\\"vps\\\": \\\"AQwC//8BYAAAAwAAAwAAAwAAAwB4AACIJElgSA==\\\", \\\"sps\\\": \\\"AQMBYAAAAwAAAwAAAwAAAwB4AACgBxIAtHfliCRJcklEEzwhERERERGEfMTzl/v/r/Nn8v/r/Mofkv9/9f5s/l/9f4zhAIOagICAggAAAwACAAADADwQ\\\", \\\"pps\\\": \\\"AcElPAiQ\\\"}\"}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Bitrate": 250120,
|
||||||
|
"QualityType": 28,
|
||||||
|
"BitrateFPS": 25,
|
||||||
|
"GearName": "adapt_540_1",
|
||||||
|
"PlayAddr": {
|
||||||
|
"DataSize": "770216",
|
||||||
|
"Width": 720,
|
||||||
|
"Height": 576,
|
||||||
|
"Uri": "v15044gf0000da3lvrfog65j150t3ong",
|
||||||
|
"UrlList": [
|
||||||
|
"https://v16-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/osHERiih4IBm2zLAfvKAfUiCqHEkR1HEVw41YB/?a=1988&bti=ODszNWYuMDE6&&bt=244&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=OWhoNjg3ZWloNGllOTs2ZkBpanJ3OG05cnVvZDMzaTczNEA1MWI2NDFfNTUxNS0uNGBeYSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=d7846c5b7492d34a5b9cd2664c7e7a74&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"https://v19-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/osHERiih4IBm2zLAfvKAfUiCqHEkR1HEVw41YB/?a=1988&bti=ODszNWYuMDE6&&bt=244&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=OWhoNjg3ZWloNGllOTs2ZkBpanJ3OG05cnVvZDMzaTczNEA1MWI2NDFfNTUxNS0uNGBeYSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=d7846c5b7492d34a5b9cd2664c7e7a74&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=acc378160b8f418e8fbb447593ee4e86&is_play_url=1&item_id=7676209593449581855&line=0&ply_type=2&pt=dGxiLmFnZW50LnRpa3Rva19sYl9hbGlzZ192MyYvQHBlYWtraWRzb25ncy92aWRlby83Njc2MjA5NTkzNDQ5NTgxODU1JjIwMjYwODI2MjE0NzE1Q0IzMTc2QzhFNzg3M0UwNkQ2RDkmMTc4Nzc1MjAzNSY4ZjNjM2ZmNzExYWI&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLmIwMjQ3NGYyNWY5ZDc4OGNkMWM0YzRjZTAwNWNjZGYx&tk=tt_chain_token&video_id=v15044gf0000da3lvrfog65j150t3ong"
|
||||||
|
],
|
||||||
|
"UrlKey": "v15044gf0000da3lvrfog65j150t3ong_bytevc1_540p_250120",
|
||||||
|
"FileHash": "7f3fc7af6716aecbbd67b87f7abf7967",
|
||||||
|
"FileCs": "c:0-21302-9d42"
|
||||||
|
},
|
||||||
|
"CodecType": "h265_hvc1",
|
||||||
|
"MVMAF": "{\"v2.0\": {\"srv1\": {\"v1080\": 91.37, \"v960\": 93.194, \"v864\": 94.225, \"v720\": 96.01}, \"ori\": {\"v1080\": 81.706, \"v960\": 84.929, \"v864\": 87.327, \"v720\": 90.535}}}",
|
||||||
|
"Format": "mp4",
|
||||||
|
"VideoExtra": "{\"PktOffsetMap\":\"[{\\\"time\\\": 1, \\\"offset\\\": 61258}, {\\\"time\\\": 2, \\\"offset\\\": 84972}, {\\\"time\\\": 3, \\\"offset\\\": 119851}, {\\\"time\\\": 4, \\\"offset\\\": 151836}, {\\\"time\\\": 5, \\\"offset\\\": 189518}, {\\\"time\\\": 10, \\\"offset\\\": 352520}]\",\"mvmaf\":\"{\\\"v2.0\\\": {\\\"srv1\\\": {\\\"v1080\\\": 91.37, \\\"v960\\\": 93.194, \\\"v864\\\": 94.225, \\\"v720\\\": 96.01}, \\\"ori\\\": {\\\"v1080\\\": 81.706, \\\"v960\\\": 84.929, \\\"v864\\\": 87.327, \\\"v720\\\": 90.535}}}\",\"ufq\":\"{\\\"enh\\\":68.246,\\\"playback\\\":{\\\"ori\\\":54.775,\\\"srv1\\\":64.439},\\\"src\\\":68.246,\\\"trans\\\":54.775,\\\"version\\\":\\\"v1.2\\\"}\",\"volume_info_json\":\"\",\"transcode_feature_id\":\"4b5574e16dede7b5389a11ced23de6fa\",\"dec_info\":\"\",\"gearvqm\":\"{\\\"v3.0\\\": {\\\"ori\\\": 90.518, \\\"sr20\\\": 99.448}}\",\"audio_bit_rate\":48378,\"ps_info\":\"{\\\"vps\\\": \\\"AQwC//8BYAAAAwAAAwAAAwAAAwBdAACIJElgSA==\\\", \\\"sps\\\": \\\"AQMBYAAAAwAAAwAAAwAAAwBdAACgBaIAkFliCRJcklEEzwhERERERGEfMTzl/v/r/Nn8v/r/Mofkv9/9f5s/l/9f4zhAIOagICAggAAAAwCAAAAPBA==\\\", \\\"pps\\\": \\\"AcElPAiQ\\\"}\"}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"size": "1943651",
|
||||||
|
"VQScore": "57.3",
|
||||||
|
"claInfo": {
|
||||||
|
"hasOriginalAudio": true,
|
||||||
|
"enableAutoCaption": true,
|
||||||
|
"captionInfos": [],
|
||||||
|
"noCaptionReason": 3
|
||||||
|
},
|
||||||
|
"videoID": "v15044gf0000da3lvrfog65j150t3ong",
|
||||||
|
"PlayAddrStruct": {
|
||||||
|
"DataSize": "1943651",
|
||||||
|
"Width": 900,
|
||||||
|
"Height": 720,
|
||||||
|
"Uri": "v15044gf0000da3lvrfog65j150t3ong",
|
||||||
|
"UrlList": [
|
||||||
|
"https://v16-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/o8lUEHfBVIzQRRqECYsvqh41HBAARif2iQB2EA/?a=1988&bti=ODszNWYuMDE6&&bt=616&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=NWZoO2Y7Z2U1Zjw8Nmg1ZUBpanJ3OG05cnVvZDMzaTczNEBiYi9hYTFiXmAxXzRfNjAuYSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=1bcd8512dd8c6dfd4afb7588d87015d5&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"https://v19-webapp-prime.tiktok.com/video/tos/alisg/tos-alisg-ve-37c799-sg/o8lUEHfBVIzQRRqECYsvqh41HBAARif2iQB2EA/?a=1988&bti=ODszNWYuMDE6&&bt=616&ft=I~da4ooVD12NvM928zIxRG0SOlBF-UjNSaopiX&mime_type=video_mp4&rc=NWZoO2Y7Z2U1Zjw8Nmg1ZUBpanJ3OG05cnVvZDMzaTczNEBiYi9hYTFiXmAxXzRfNjAuYSNrMC0uMmRjczBhLS1kMTJzcw%3D%3D&expire=1787924859&l=20260826214715CB3176C8E7873E06D6D9&ply_type=2&policy=2&signature=1bcd8512dd8c6dfd4afb7588d87015d5&tk=tt_chain_token&btag=e000b8000",
|
||||||
|
"https://www.tiktok.com/aweme/v1/play/?faid=1988&file_id=1b329eb379b94cc2abed48dcd82eece2&is_play_url=1&item_id=7676209593449581855&line=0&ply_type=2&pt=dGxiLmFnZW50LnRpa3Rva19sYl9hbGlzZ192MyYvQHBlYWtraWRzb25ncy92aWRlby83Njc2MjA5NTkzNDQ5NTgxODU1JjIwMjYwODI2MjE0NzE1Q0IzMTc2QzhFNzg3M0UwNkQ2RDkmMTc4Nzc1MjAzNSY4ZjNjM2ZmNzExYWI&signaturev3=dmlkZW9faWQ7ZmlsZV9pZDtpdGVtX2lkLjY2NGNiZDRlN2MwYTFhYzgyMTAwNjE5MjQ1NmZlOThk&tk=tt_chain_token&video_id=v15044gf0000da3lvrfog65j150t3ong"
|
||||||
|
],
|
||||||
|
"UrlKey": "v15044gf0000da3lvrfog65j150t3ong_h264_720p_631183",
|
||||||
|
"FileHash": "c7390a70616856b596a7287a8c71c23f",
|
||||||
|
"FileCs": "c:0-21152-6062"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"id": "7655005967730476046",
|
||||||
|
"shortId": "",
|
||||||
|
"uniqueId": "peakkidsongs",
|
||||||
|
"nickname": "best kid songs",
|
||||||
|
"avatarLarger": "https://p16-common-sign.tiktokcdn.com/tos-maliva-avt-0068/5dc1d86fc99242bbfeb29e3e2dab1911~tplv-tiktokx-cropcenter:1080:1080.jpeg?dr=14579&refresh_token=2f274de4&x-expires=1787922000&x-signature=tErBys2dK1eLk4%2BxHMxkW%2F%2FrV3k%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=sg1",
|
||||||
|
"avatarMedium": "https://p16-common-sign.tiktokcdn.com/tos-maliva-avt-0068/5dc1d86fc99242bbfeb29e3e2dab1911~tplv-tiktokx-cropcenter:720:720.jpeg?dr=14579&refresh_token=f1075bfe&x-expires=1787922000&x-signature=zJ3u%2FXHx3fA%2Fdxcq1kM%2Bdhl9208%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=sg1",
|
||||||
|
"avatarThumb": "https://p16-common-sign.tiktokcdn.com/tos-maliva-avt-0068/5dc1d86fc99242bbfeb29e3e2dab1911~tplv-tiktokx-cropcenter:100:100.jpeg?dr=14579&refresh_token=7e9f28bd&x-expires=1787922000&x-signature=BwnNlyLTSiqT%2BwKzR7Ql4StkOo0%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=sg1",
|
||||||
|
"signature": "the best kid songs",
|
||||||
|
"createTime": 1782319994,
|
||||||
|
"verified": false,
|
||||||
|
"secUid": "MS4wLjABAAAAH8Vp7bGUS0TVmjdkKvWrs-tGyZwKzuP929gdN-Fns6ACs3TEDQiZ_BD3_1KRd1RB",
|
||||||
|
"ftc": false,
|
||||||
|
"relation": 0,
|
||||||
|
"openFavorite": false,
|
||||||
|
"commentSetting": 0,
|
||||||
|
"duetSetting": 0,
|
||||||
|
"stitchSetting": 0,
|
||||||
|
"privateAccount": false,
|
||||||
|
"secret": false,
|
||||||
|
"isADVirtual": false,
|
||||||
|
"roomId": "",
|
||||||
|
"uniqueIdModifyTime": 0,
|
||||||
|
"ttSeller": false,
|
||||||
|
"downloadSetting": 0,
|
||||||
|
"recommendReason": "",
|
||||||
|
"nowInvitationCardUrl": "",
|
||||||
|
"nickNameModifyTime": 0,
|
||||||
|
"isEmbedBanned": false,
|
||||||
|
"canExpPlaylist": false,
|
||||||
|
"suggestAccountBind": false,
|
||||||
|
"UserStoryStatus": 0,
|
||||||
|
"shortDramaCreator": {}
|
||||||
|
},
|
||||||
|
"music": {
|
||||||
|
"id": "7676209521634577183",
|
||||||
|
"title": "original sound",
|
||||||
|
"playUrl": "https://v16m.tiktokcdn.com/c158c94601a84f8766ba69ce650255cf/6a903ffb/video/tos/alisg/tos-alisg-v-2370c799-sg/oMvwfffY4AQAK7BKsAEgrQAOq6AigfB6veGgff1/?a=1233&bti=ODszNWYuMDE6&&bt=125&ft=GcDrcInz7ThFB7kGXq8Zmo&mime_type=audio_mpeg&rc=M3FvNXk5cnlvZDMzaTU8NEBpM3FvNXk5cnlvZDMzaTU8NEBjZmJwMmRzczBhLS1kMTJzYSNjZmJwMmRzczBhLS1kMTJzcw%3D%3D&vvpl=1&l=20260826214715CB3176C8E7873E06D6D9&btag=e00078000&sp_exp=hash_v0",
|
||||||
|
"coverLarge": "https://p16-common-sign.tiktokcdn.com/tos-maliva-avt-0068/5dc1d86fc99242bbfeb29e3e2dab1911~tplv-tiktokx-cropcenter:1080:1080.jpeg?dr=14579&refresh_token=2f274de4&x-expires=1787922000&x-signature=tErBys2dK1eLk4%2BxHMxkW%2F%2FrV3k%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=sg1",
|
||||||
|
"coverMedium": "https://p16-common-sign.tiktokcdn.com/tos-maliva-avt-0068/5dc1d86fc99242bbfeb29e3e2dab1911~tplv-tiktokx-cropcenter:720:720.jpeg?dr=14579&refresh_token=f1075bfe&x-expires=1787922000&x-signature=zJ3u%2FXHx3fA%2Fdxcq1kM%2Bdhl9208%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=sg1",
|
||||||
|
"coverThumb": "https://p16-common-sign.tiktokcdn.com/tos-maliva-avt-0068/5dc1d86fc99242bbfeb29e3e2dab1911~tplv-tiktokx-cropcenter:100:100.jpeg?dr=14579&refresh_token=7e9f28bd&x-expires=1787922000&x-signature=BwnNlyLTSiqT%2BwKzR7Ql4StkOo0%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=sg1",
|
||||||
|
"authorName": "best kid songs",
|
||||||
|
"original": true,
|
||||||
|
"private": false,
|
||||||
|
"duration": 24,
|
||||||
|
"scheduleSearchTime": 0,
|
||||||
|
"collected": false,
|
||||||
|
"preciseDuration": {
|
||||||
|
"preciseDuration": 24.685688,
|
||||||
|
"preciseShootDuration": 24.685688,
|
||||||
|
"preciseAuditionDuration": 24.685688,
|
||||||
|
"preciseVideoDuration": 24.685688
|
||||||
|
},
|
||||||
|
"isCopyrighted": true,
|
||||||
|
"tt2dsp": {
|
||||||
|
"tt_to_dsp_song_infos": [
|
||||||
|
{
|
||||||
|
"ShowStrategy": "1",
|
||||||
|
"Platform": 1,
|
||||||
|
"SongId": "1816011966",
|
||||||
|
"Token": {
|
||||||
|
"AppleMusicToken": {
|
||||||
|
"DeveloperToken": "eyJhbGciOiJFUzI1NiIsImtpZCI6Ikc2Q0dLMjdWQzMifQ.eyJleHAiOjE3ODc5MDc4NDcsImlhdCI6MTc4NzY0ODY0NywiaXNzIjoiTUo3OTdEOFU2RiJ9.uBwnfLRjoQbior6QL_oD0ogEcKBFkogh6tpVtnyNxtbcPR94Po18wyX6nMigfK7UgvTo05GQcrvNT3EMsZ1DEQ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MetaSongId": "6701942024200783873"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ShowStrategy": "1",
|
||||||
|
"Platform": 3,
|
||||||
|
"SongId": "0QsKEPcviDIqoOdtZvbixA",
|
||||||
|
"MetaSongId": "6701942024200783873"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is_unlimited_music": false,
|
||||||
|
"is_commerce_music": true,
|
||||||
|
"shoot_duration": 24
|
||||||
|
},
|
||||||
|
"challenges": [
|
||||||
|
{
|
||||||
|
"id": "334897",
|
||||||
|
"title": "hoteltransylvania",
|
||||||
|
"desc": "",
|
||||||
|
"profileLarger": "",
|
||||||
|
"profileMedium": "",
|
||||||
|
"profileThumb": "",
|
||||||
|
"coverLarger": "",
|
||||||
|
"coverMedium": "",
|
||||||
|
"coverThumb": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5636",
|
||||||
|
"title": "music",
|
||||||
|
"desc": "",
|
||||||
|
"profileLarger": "",
|
||||||
|
"profileMedium": "",
|
||||||
|
"profileThumb": "",
|
||||||
|
"coverLarger": "",
|
||||||
|
"coverMedium": "",
|
||||||
|
"coverThumb": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"diggCount": 191200,
|
||||||
|
"shareCount": 4987,
|
||||||
|
"commentCount": 570,
|
||||||
|
"playCount": 836900,
|
||||||
|
"collectCount": "28320"
|
||||||
|
},
|
||||||
|
"statsV2": {
|
||||||
|
"diggCount": "191200",
|
||||||
|
"shareCount": "4987",
|
||||||
|
"commentCount": "570",
|
||||||
|
"playCount": "836900",
|
||||||
|
"collectCount": "28320",
|
||||||
|
"repostCount": "0"
|
||||||
|
},
|
||||||
|
"warnInfo": [],
|
||||||
|
"originalItem": false,
|
||||||
|
"officalItem": false,
|
||||||
|
"textExtra": [
|
||||||
|
{
|
||||||
|
"awemeId": "",
|
||||||
|
"start": 65,
|
||||||
|
"end": 83,
|
||||||
|
"hashtagId": "334897",
|
||||||
|
"hashtagName": "hoteltransylvania",
|
||||||
|
"type": 1,
|
||||||
|
"subType": 0,
|
||||||
|
"isCommerce": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"awemeId": "",
|
||||||
|
"start": 84,
|
||||||
|
"end": 90,
|
||||||
|
"hashtagId": "5636",
|
||||||
|
"hashtagName": "music",
|
||||||
|
"type": 1,
|
||||||
|
"subType": 0,
|
||||||
|
"isCommerce": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"awemeId": "",
|
||||||
|
"start": 91,
|
||||||
|
"end": 100,
|
||||||
|
"hashtagName": "",
|
||||||
|
"type": 0,
|
||||||
|
"subType": 0,
|
||||||
|
"userId": "6634514019254992901",
|
||||||
|
"isCommerce": false,
|
||||||
|
"userUniqueId": "joejonas",
|
||||||
|
"secUid": "MS4wLjABAAAAem6jUHqet8yKHm7RpYyTg55E1MFm8COP4g77w8Byv_AWcXkbYRwew2DE_gTEy69c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secret": false,
|
||||||
|
"forFriend": false,
|
||||||
|
"digged": false,
|
||||||
|
"itemCommentStatus": 0,
|
||||||
|
"takeDown": 0,
|
||||||
|
"effectStickers": [],
|
||||||
|
"authorStats": {
|
||||||
|
"followerCount": 464,
|
||||||
|
"followingCount": 5,
|
||||||
|
"heart": 224800,
|
||||||
|
"heartCount": 224800,
|
||||||
|
"videoCount": 5,
|
||||||
|
"diggCount": 166,
|
||||||
|
"friendCount": 0
|
||||||
|
},
|
||||||
|
"privateItem": false,
|
||||||
|
"duetEnabled": true,
|
||||||
|
"stitchEnabled": true,
|
||||||
|
"stickersOnItem": [
|
||||||
|
{
|
||||||
|
"stickerText": [
|
||||||
|
"whoever sang \nthis i love you"
|
||||||
|
],
|
||||||
|
"stickerType": 4
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isAd": false,
|
||||||
|
"shareEnabled": true,
|
||||||
|
"comments": [],
|
||||||
|
"duetDisplay": 0,
|
||||||
|
"stitchDisplay": 0,
|
||||||
|
"indexEnabled": true,
|
||||||
|
"diversificationLabels": [
|
||||||
|
"Comics & Cartoon, Anime",
|
||||||
|
"Anime & Comics",
|
||||||
|
"Entertainment"
|
||||||
|
],
|
||||||
|
"locationCreated": "US",
|
||||||
|
"suggestedWords": [
|
||||||
|
"joe jonas hotel transylvania",
|
||||||
|
"hotel transylvania songs",
|
||||||
|
"it's party time hotel transylvania",
|
||||||
|
"joe jonas",
|
||||||
|
"hotel transylvania",
|
||||||
|
"joe jonas it's party time",
|
||||||
|
"Joe Jonas Singing",
|
||||||
|
"Transylvania",
|
||||||
|
"mavis hotel transylvania",
|
||||||
|
"Joe Jonas Edits"
|
||||||
|
],
|
||||||
|
"contents": [
|
||||||
|
{
|
||||||
|
"desc": "this song is from hotel Transylvania 3 and it gets me everytime….#hoteltransylvania #music @joejonas ",
|
||||||
|
"textExtra": [
|
||||||
|
{
|
||||||
|
"awemeId": "",
|
||||||
|
"start": 65,
|
||||||
|
"end": 83,
|
||||||
|
"hashtagId": "334897",
|
||||||
|
"hashtagName": "hoteltransylvania",
|
||||||
|
"type": 1,
|
||||||
|
"subType": 0,
|
||||||
|
"isCommerce": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"awemeId": "",
|
||||||
|
"start": 84,
|
||||||
|
"end": 90,
|
||||||
|
"hashtagId": "5636",
|
||||||
|
"hashtagName": "music",
|
||||||
|
"type": 1,
|
||||||
|
"subType": 0,
|
||||||
|
"isCommerce": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"awemeId": "",
|
||||||
|
"start": 91,
|
||||||
|
"end": 100,
|
||||||
|
"hashtagName": "",
|
||||||
|
"type": 0,
|
||||||
|
"subType": 0,
|
||||||
|
"userId": "6634514019254992901",
|
||||||
|
"isCommerce": false,
|
||||||
|
"userUniqueId": "joejonas",
|
||||||
|
"secUid": "MS4wLjABAAAAem6jUHqet8yKHm7RpYyTg55E1MFm8COP4g77w8Byv_AWcXkbYRwew2DE_gTEy69c"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"diversificationId": 10052,
|
||||||
|
"collected": false,
|
||||||
|
"videoSuggestWordsList": {
|
||||||
|
"video_suggest_words_struct": [
|
||||||
|
{
|
||||||
|
"words": [
|
||||||
|
{
|
||||||
|
"word": "Joe jonas",
|
||||||
|
"word_id": "2227259983527610020"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "joe jonas",
|
||||||
|
"word_id": "2909873504755415680"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Joe Jonas",
|
||||||
|
"word_id": "4108238894334256339"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "Joe Jones",
|
||||||
|
"word_id": "307968296341200538"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"word": "joe jonas octopus",
|
||||||
|
"word_id": "5016982707054551529"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scene": "comment_top",
|
||||||
|
"hint_text": "Search:"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"words": [
|
||||||
|
{
|
||||||
|
"word": "Joe jonas",
|
||||||
|
"word_id": "2227259983527610020"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scene": "feed_bar",
|
||||||
|
"hint_text": "Search · "
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"channelTags": [],
|
||||||
|
"item_control": {
|
||||||
|
"can_repost": true
|
||||||
|
},
|
||||||
|
"IsAigc": false,
|
||||||
|
"AIGCDescription": "",
|
||||||
|
"ShowAIGC": true,
|
||||||
|
"backendSourceEventTracking": "",
|
||||||
|
"CategoryType": 100,
|
||||||
|
"textLanguage": "en",
|
||||||
|
"textTranslatable": true,
|
||||||
|
"authorStatsV2": {
|
||||||
|
"followerCount": "464",
|
||||||
|
"followingCount": "5",
|
||||||
|
"heart": "224800",
|
||||||
|
"heartCount": "224800",
|
||||||
|
"videoCount": "5",
|
||||||
|
"diggCount": "166",
|
||||||
|
"friendCount": "0"
|
||||||
|
},
|
||||||
|
"isReviewing": false,
|
||||||
|
"creatorAIComment": {
|
||||||
|
"hasAITopic": false,
|
||||||
|
"categoryList": [],
|
||||||
|
"eligibleVideo": false,
|
||||||
|
"notEligibleReason": 101
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+286
@@ -0,0 +1,286 @@
|
|||||||
|
{
|
||||||
|
"__typename": "Tweet",
|
||||||
|
"favorite_count": 669102,
|
||||||
|
"lang": "in",
|
||||||
|
"possibly_sensitive": false,
|
||||||
|
"created_at": "2026-04-03T21:13:29.000Z",
|
||||||
|
"display_text_range": [
|
||||||
|
0,
|
||||||
|
35
|
||||||
|
],
|
||||||
|
"entities": {
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"display_url": "pic.x.com/wGc2wtY0e2",
|
||||||
|
"expanded_url": "https://x.com/NASA/status/2040175881360941184/photo/1",
|
||||||
|
"indices": [
|
||||||
|
36,
|
||||||
|
59
|
||||||
|
],
|
||||||
|
"url": "https://t.co/wGc2wtY0e2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"id_str": "2040175881360941184",
|
||||||
|
"text": "1972 ➡️2026\nApollo 17 ➡️ Artemis II https://t.co/wGc2wtY0e2",
|
||||||
|
"user": {
|
||||||
|
"id_str": "11348282",
|
||||||
|
"name": "NASA",
|
||||||
|
"screen_name": "NASA",
|
||||||
|
"is_blue_verified": true,
|
||||||
|
"profile_image_shape": "Square",
|
||||||
|
"verified": false,
|
||||||
|
"verified_type": "Government",
|
||||||
|
"profile_image_url_https": "https://pbs.twimg.com/profile_images/1321163587679784960/0ZxKlEKB_normal.jpg"
|
||||||
|
},
|
||||||
|
"edit_control": {
|
||||||
|
"edit_tweet_ids": [
|
||||||
|
"2040175881360941184"
|
||||||
|
],
|
||||||
|
"editable_until_msecs": "1775254409000",
|
||||||
|
"edits_remaining": "5",
|
||||||
|
"is_edit_eligible": false
|
||||||
|
},
|
||||||
|
"mediaDetails": [
|
||||||
|
{
|
||||||
|
"display_url": "pic.x.com/wGc2wtY0e2",
|
||||||
|
"expanded_url": "https://x.com/NASA/status/2040175881360941184/photo/1",
|
||||||
|
"ext_alt_text": "Photograph of Earth captured by the Apollo 17 crew on December 7, 1972. Earth takes up most of the image. Dark blue ocean water and swirling white clouds are visible. Africa is a large brown landmass from the center to the upper left, stretching from about 9 to 12 o'clock. Clouds cover most of the 6 o'clock. Credit: NASA",
|
||||||
|
"ext_media_availability": {
|
||||||
|
"status": "Available"
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
36,
|
||||||
|
59
|
||||||
|
],
|
||||||
|
"media_url_https": "https://pbs.twimg.com/media/HFApJg2XUAARgza.jpg",
|
||||||
|
"original_info": {
|
||||||
|
"focus_rects": [
|
||||||
|
{
|
||||||
|
"h": 1053,
|
||||||
|
"w": 1881,
|
||||||
|
"x": 0,
|
||||||
|
"y": 828
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1881,
|
||||||
|
"w": 1881,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1881,
|
||||||
|
"w": 1650,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1881,
|
||||||
|
"w": 941,
|
||||||
|
"x": 47,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1881,
|
||||||
|
"w": 1881,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"height": 1881,
|
||||||
|
"width": 1881
|
||||||
|
},
|
||||||
|
"sizes": {
|
||||||
|
"large": {
|
||||||
|
"h": 1881,
|
||||||
|
"resize": "fit",
|
||||||
|
"w": 1881
|
||||||
|
},
|
||||||
|
"medium": {
|
||||||
|
"h": 1200,
|
||||||
|
"resize": "fit",
|
||||||
|
"w": 1200
|
||||||
|
},
|
||||||
|
"small": {
|
||||||
|
"h": 680,
|
||||||
|
"resize": "fit",
|
||||||
|
"w": 680
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"h": 150,
|
||||||
|
"resize": "crop",
|
||||||
|
"w": 150
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "photo",
|
||||||
|
"url": "https://t.co/wGc2wtY0e2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"display_url": "pic.x.com/wGc2wtY0e2",
|
||||||
|
"expanded_url": "https://x.com/NASA/status/2040175881360941184/photo/1",
|
||||||
|
"ext_alt_text": "A full disk image of Earth, as seen from the Orion capsule. The planet is a pale blue, swirling with white clouds and glowing slightly lighter blue in place from reflected light. From about 8 to 9 o'clock, a large brown landmass is Africa, with the Iberian peninsula twinkling with lights just where the planet curves. At the 1 o'clock spot, aurora glow in a thin green glow, just barely separated from the planet's surface. Earth is set against the black of space. Credit: NASA/Reid Wiseman",
|
||||||
|
"ext_media_availability": {
|
||||||
|
"status": "Available"
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
36,
|
||||||
|
59
|
||||||
|
],
|
||||||
|
"media_url_https": "https://pbs.twimg.com/media/HFApJu1WcAA3OfN.jpg",
|
||||||
|
"original_info": {
|
||||||
|
"focus_rects": [
|
||||||
|
{
|
||||||
|
"h": 1075,
|
||||||
|
"w": 1920,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1280,
|
||||||
|
"w": 1280,
|
||||||
|
"x": 272,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1280,
|
||||||
|
"w": 1123,
|
||||||
|
"x": 351,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1280,
|
||||||
|
"w": 640,
|
||||||
|
"x": 592,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1280,
|
||||||
|
"w": 1920,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"height": 1280,
|
||||||
|
"width": 1920
|
||||||
|
},
|
||||||
|
"sizes": {
|
||||||
|
"large": {
|
||||||
|
"h": 1280,
|
||||||
|
"resize": "fit",
|
||||||
|
"w": 1920
|
||||||
|
},
|
||||||
|
"medium": {
|
||||||
|
"h": 800,
|
||||||
|
"resize": "fit",
|
||||||
|
"w": 1200
|
||||||
|
},
|
||||||
|
"small": {
|
||||||
|
"h": 453,
|
||||||
|
"resize": "fit",
|
||||||
|
"w": 680
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"h": 150,
|
||||||
|
"resize": "crop",
|
||||||
|
"w": 150
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "photo",
|
||||||
|
"url": "https://t.co/wGc2wtY0e2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"photos": [
|
||||||
|
{
|
||||||
|
"accessibilityLabel": "Photograph of Earth captured by the Apollo 17 crew on December 7, 1972. Earth takes up most of the image. Dark blue ocean water and swirling white clouds are visible. Africa is a large brown landmass from the center to the upper left, stretching from about 9 to 12 o'clock. Clouds cover most of the 6 o'clock. Credit: NASA",
|
||||||
|
"backgroundColor": {
|
||||||
|
"red": 204,
|
||||||
|
"green": 214,
|
||||||
|
"blue": 221
|
||||||
|
},
|
||||||
|
"cropCandidates": [
|
||||||
|
{
|
||||||
|
"h": 1053,
|
||||||
|
"w": 1881,
|
||||||
|
"x": 0,
|
||||||
|
"y": 828
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1881,
|
||||||
|
"w": 1881,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1881,
|
||||||
|
"w": 1650,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1881,
|
||||||
|
"w": 941,
|
||||||
|
"x": 47,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1881,
|
||||||
|
"w": 1881,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expandedUrl": "https://x.com/NASA/status/2040175881360941184/photo/1",
|
||||||
|
"url": "https://pbs.twimg.com/media/HFApJg2XUAARgza.jpg",
|
||||||
|
"width": 1881,
|
||||||
|
"height": 1881
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"accessibilityLabel": "A full disk image of Earth, as seen from the Orion capsule. The planet is a pale blue, swirling with white clouds and glowing slightly lighter blue in place from reflected light. From about 8 to 9 o'clock, a large brown landmass is Africa, with the Iberian peninsula twinkling with lights just where the planet curves. At the 1 o'clock spot, aurora glow in a thin green glow, just barely separated from the planet's surface. Earth is set against the black of space. Credit: NASA/Reid Wiseman",
|
||||||
|
"backgroundColor": {
|
||||||
|
"red": 204,
|
||||||
|
"green": 214,
|
||||||
|
"blue": 221
|
||||||
|
},
|
||||||
|
"cropCandidates": [
|
||||||
|
{
|
||||||
|
"h": 1075,
|
||||||
|
"w": 1920,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1280,
|
||||||
|
"w": 1280,
|
||||||
|
"x": 272,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1280,
|
||||||
|
"w": 1123,
|
||||||
|
"x": 351,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1280,
|
||||||
|
"w": 640,
|
||||||
|
"x": 592,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"h": 1280,
|
||||||
|
"w": 1920,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expandedUrl": "https://x.com/NASA/status/2040175881360941184/photo/1",
|
||||||
|
"url": "https://pbs.twimg.com/media/HFApJu1WcAA3OfN.jpg",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1280
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conversation_count": 7385,
|
||||||
|
"news_action_type": "conversation",
|
||||||
|
"isEdited": false,
|
||||||
|
"isStaleEdit": false
|
||||||
|
}
|
||||||
Vendored
+46
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"__typename": "Tweet",
|
||||||
|
"favorite_count": 309220,
|
||||||
|
"lang": "en",
|
||||||
|
"created_at": "2006-03-21T20:50:14.000Z",
|
||||||
|
"display_text_range": [
|
||||||
|
0,
|
||||||
|
24
|
||||||
|
],
|
||||||
|
"entities": {},
|
||||||
|
"id_str": "20",
|
||||||
|
"text": "just setting up my twttr",
|
||||||
|
"user": {
|
||||||
|
"id_str": "12",
|
||||||
|
"name": "jack",
|
||||||
|
"screen_name": "jack",
|
||||||
|
"is_blue_verified": true,
|
||||||
|
"profile_image_shape": "Circle",
|
||||||
|
"verified": false,
|
||||||
|
"profile_image_url_https": "https://pbs.twimg.com/profile_images/1661201415899951105/azNjKOSH_normal.jpg",
|
||||||
|
"highlighted_label": {
|
||||||
|
"badge": {
|
||||||
|
"url": "https://pbs.twimg.com/profile_images/1285655593592791040/HtwPZgej_bigger.jpg"
|
||||||
|
},
|
||||||
|
"description": "Square",
|
||||||
|
"url": {
|
||||||
|
"url": "https://twitter.com/Square",
|
||||||
|
"url_type": "DeepLink"
|
||||||
|
},
|
||||||
|
"user_label_display_type": "Badge",
|
||||||
|
"user_label_type": "BusinessLabel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edit_control": {
|
||||||
|
"edit_tweet_ids": [
|
||||||
|
"20"
|
||||||
|
],
|
||||||
|
"editable_until_msecs": "1142976014000",
|
||||||
|
"edits_remaining": "5",
|
||||||
|
"is_edit_eligible": true
|
||||||
|
},
|
||||||
|
"conversation_count": 17998,
|
||||||
|
"news_action_type": "conversation",
|
||||||
|
"isEdited": false,
|
||||||
|
"isStaleEdit": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const root = join(import.meta.dirname, 'fixtures');
|
||||||
|
|
||||||
|
/** Load a captured upstream payload. These are real responses, trimmed to
|
||||||
|
* the object each adapter actually reads. */
|
||||||
|
export function fixture<T>(path: string): T {
|
||||||
|
return JSON.parse(readFileSync(join(root, path), 'utf8')) as T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { fromGraph } from '../src/platforms/instagram.ts';
|
||||||
|
import { fixture } from './helpers.ts';
|
||||||
|
|
||||||
|
test('a carousel becomes one media entry per child, in order', () => {
|
||||||
|
const scraped = fromGraph(fixture('instagram/carousel.json'));
|
||||||
|
assert.ok(scraped.media.length > 1, 'expected a carousel');
|
||||||
|
assert.ok(scraped.media.every((m) => m.kind === 'image'));
|
||||||
|
assert.ok(scraped.media.every((m) => m.url.startsWith('https://')));
|
||||||
|
// The CDN 403s anything arriving without a referrer, so every entry has
|
||||||
|
// to carry the headers the proxy will replay.
|
||||||
|
assert.ok(scraped.media.every((m) => m.fetchHeaders?.['Referer'] === 'https://www.instagram.com/'));
|
||||||
|
assert.ok(scraped.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reel becomes a progressive video with its cover frame as the poster', () => {
|
||||||
|
const scraped = fromGraph(fixture('instagram/reel.json'));
|
||||||
|
assert.equal(scraped.media.length, 1);
|
||||||
|
const [item] = scraped.media;
|
||||||
|
assert.equal(item?.kind, 'video');
|
||||||
|
assert.ok(item?.kind === 'video' && item.poster, 'expected a poster');
|
||||||
|
// Not a blob: or an HLS manifest — the native player needs a real file.
|
||||||
|
assert.match(item?.url ?? '', /^https:\/\//);
|
||||||
|
assert.ok(!item?.url.includes('.m3u8'));
|
||||||
|
assert.ok(item?.width && item.height, 'expected dimensions for the aspect ratio');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('captions keep their line breaks', () => {
|
||||||
|
const scraped = fromGraph(fixture('instagram/carousel.json'));
|
||||||
|
assert.ok(scraped.text?.includes('\n'), 'expected the caption to keep paragraph breaks');
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { findCaption, findMetaMedia, mediaFromMetaNode } from '../src/platforms/meta-media.ts';
|
||||||
|
import { fixture } from './helpers.ts';
|
||||||
|
|
||||||
|
const REFERER = 'https://www.threads.com/';
|
||||||
|
|
||||||
|
test('a Threads image post normalizes to one image with referrer headers', () => {
|
||||||
|
const node = fixture<Parameters<typeof mediaFromMetaNode>[0]>('threads/image.json');
|
||||||
|
const media = mediaFromMetaNode(node, REFERER);
|
||||||
|
assert.equal(media.length, 1);
|
||||||
|
assert.equal(media[0]?.kind, 'image');
|
||||||
|
assert.equal(media[0]?.fetchHeaders?.['Referer'], REFERER);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second real Threads post normalizes the same way', () => {
|
||||||
|
// Two captures, so the walk is not quietly fitted to one payload.
|
||||||
|
const media = mediaFromMetaNode(fixture('threads/second.json'), REFERER);
|
||||||
|
assert.equal(media.length, 1);
|
||||||
|
assert.equal(media[0]?.kind, 'image');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the largest candidate wins', () => {
|
||||||
|
const media = mediaFromMetaNode(
|
||||||
|
{
|
||||||
|
image_versions2: {
|
||||||
|
candidates: [
|
||||||
|
{ url: 'https://cdn/small.jpg', width: 320, height: 320 },
|
||||||
|
{ url: 'https://cdn/large.jpg', width: 1440, height: 1440 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
REFERER,
|
||||||
|
);
|
||||||
|
assert.equal(media[0]?.url, 'https://cdn/large.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a carousel flattens to its children', () => {
|
||||||
|
const media = mediaFromMetaNode(
|
||||||
|
{
|
||||||
|
carousel_media: [
|
||||||
|
{ image_versions2: { candidates: [{ url: 'https://cdn/1.jpg', width: 100 }] } },
|
||||||
|
{
|
||||||
|
image_versions2: { candidates: [{ url: 'https://cdn/poster.jpg', width: 100 }] },
|
||||||
|
video_versions: [{ url: 'https://cdn/2.mp4', width: 720 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
REFERER,
|
||||||
|
);
|
||||||
|
assert.deepEqual(media.map((m) => m.kind), ['image', 'video']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the media node is found however deeply it is buried', () => {
|
||||||
|
const real = { image_versions2: { candidates: [{ url: 'https://cdn/i.jpg', width: 100 }] }, tag: 'me' };
|
||||||
|
const found = findMetaMedia({ a: [{ b: { c: real } }] });
|
||||||
|
assert.equal((found as { tag?: string })?.tag, 'me');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stub node with no candidates is not mistaken for the post', () => {
|
||||||
|
// These payloads are full of empty placeholders; picking one used to make
|
||||||
|
// a real post look like it had no media at all.
|
||||||
|
assert.equal(findMetaMedia({ image_versions2: { candidates: [] }, carousel_media: null }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the caption is found the same way, and an empty one is not a caption', () => {
|
||||||
|
assert.equal(findCaption({ x: { caption: { text: 'hello' } } }), 'hello');
|
||||||
|
assert.equal(findCaption({ x: { caption: { text: '' } } }), undefined);
|
||||||
|
assert.equal(findCaption({ x: { caption: null } }), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { escapeHtml, html, raw } from '../src/render/html.ts';
|
||||||
|
import { linkify } from '../src/render/text.ts';
|
||||||
|
import { renderPost } from '../src/render/post.ts';
|
||||||
|
import { renderError } from '../src/render/error.ts';
|
||||||
|
import type { Post } from '../src/types.ts';
|
||||||
|
|
||||||
|
test('interpolations are escaped, Raw values are not', () => {
|
||||||
|
assert.equal(html`<p>${'<script>alert(1)</script>'}</p>`.value,
|
||||||
|
'<p><script>alert(1)</script></p>');
|
||||||
|
assert.equal(html`<p>${raw('<b>ok</b>')}</p>`.value, '<p><b>ok</b></p>');
|
||||||
|
assert.equal(html`${['a', '<b>']}`.value, 'a<b>');
|
||||||
|
assert.equal(html`${undefined}${null}${false}`.value, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attribute-breaking characters are escaped', () => {
|
||||||
|
assert.equal(escapeHtml(`" onload='x'`), '" onload='x'');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('post text is escaped before links are made', () => {
|
||||||
|
const out = linkify('<img src=x onerror=alert(1)> https://example.com/a', 'x').value;
|
||||||
|
assert.ok(!out.includes('<img'), 'markup in post text must not survive');
|
||||||
|
assert.ok(out.includes('<a href="https://example.com/a"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mentions and hashtags point back at the right platform', () => {
|
||||||
|
assert.ok(linkify('hi @nasa', 'tiktok').value.includes('https://www.tiktok.com/@nasa'));
|
||||||
|
assert.ok(linkify('hi @nasa', 'bluesky').value.includes('https://bsky.app/profile/nasa'));
|
||||||
|
// Threads has no hashtag pages, so a tag stays plain text there.
|
||||||
|
assert.ok(!linkify('#moon', 'threads').value.includes('<a '));
|
||||||
|
assert.ok(linkify('#moon', 'instagram').value.includes('explore/tags/moon'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('newlines become line breaks', () => {
|
||||||
|
assert.equal(linkify('a\nb', 'x').value, 'a<br>b');
|
||||||
|
});
|
||||||
|
|
||||||
|
function post(overrides: Partial<Post> = {}): Post {
|
||||||
|
return {
|
||||||
|
platform: 'instagram',
|
||||||
|
platformLabel: 'Instagram',
|
||||||
|
originalUrl: 'https://www.instagram.com/p/ABC/',
|
||||||
|
author: { handle: '@nasa' },
|
||||||
|
text: 'caption',
|
||||||
|
textPosition: 'below',
|
||||||
|
media: [{ kind: 'image', url: 'https://cdn/1.jpg' }, { kind: 'image', url: 'https://cdn/2.jpg' }],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('media is proxied, never linked straight at the CDN', () => {
|
||||||
|
const page = renderPost(post());
|
||||||
|
assert.ok(!page.includes('https://cdn/1.jpg'), 'upstream URLs must not reach the page');
|
||||||
|
assert.equal((page.match(/src="\/m\//g) ?? []).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an HLS video is linked directly, because a proxy cannot rewrite a playlist', () => {
|
||||||
|
const page = renderPost(post({
|
||||||
|
media: [{ 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'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('text sits below the media for Instagram and above it for X', () => {
|
||||||
|
const below = renderPost(post());
|
||||||
|
assert.ok(below.indexOf('class="media"') < below.indexOf('class="text"'));
|
||||||
|
|
||||||
|
const above = renderPost(post({ platform: 'x', platformLabel: 'X', textPosition: 'above' }));
|
||||||
|
assert.ok(above.indexOf('class="text"') < above.indexOf('class="media"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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({ media: [{ kind: 'image', url: 'https://cdn/1.jpg' }] }))
|
||||||
|
.includes('data-view="grid"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a video gets native controls and a source, not an iframe', () => {
|
||||||
|
const page = renderPost(post({ media: [{ kind: 'video', url: 'https://cdn/v.mp4' }] }));
|
||||||
|
assert.ok(page.includes('<video'));
|
||||||
|
assert.ok(page.includes('controls'));
|
||||||
|
assert.ok(page.includes('playsinline'));
|
||||||
|
assert.ok(!page.includes('<iframe'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the copy button carries the clean original URL, and so does the page text', () => {
|
||||||
|
const page = renderPost(post());
|
||||||
|
assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"'));
|
||||||
|
assert.ok(page.includes('<code>https://www.instagram.com/p/ABC/</code>'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failure still hands the link back', () => {
|
||||||
|
const page = renderError({
|
||||||
|
platform: 'tiktok',
|
||||||
|
platformLabel: 'TikTok',
|
||||||
|
originalUrl: 'https://www.tiktok.com/@a/video/1',
|
||||||
|
heading: 'Could not read that TikTok 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('Open on TikTok'));
|
||||||
|
assert.ok(page.includes('verification puzzle'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { extractJsonObjectAfterKey } from '../src/platforms/scan.ts';
|
||||||
|
|
||||||
|
test('a plain payload is read straight out', () => {
|
||||||
|
const source = `window.x = {"a":1,"shortcode_media":{"id":"7","is_video":false},"b":2};`;
|
||||||
|
assert.deepEqual(extractJsonObjectAfterKey(source, 'shortcode_media'), { id: '7', is_video: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('braces inside strings do not end the object', () => {
|
||||||
|
const source = `{"shortcode_media":{"caption":"a } b { c","id":"7"}}`;
|
||||||
|
assert.deepEqual(extractJsonObjectAfterKey(source, 'shortcode_media'), { caption: 'a } b { c', id: '7' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an escaped quote inside a string does not end the string', () => {
|
||||||
|
const source = String.raw`{"shortcode_media":{"caption":"she said \"hi\" }","id":"7"}}`;
|
||||||
|
const found = extractJsonObjectAfterKey<{ caption: string }>(source, 'shortcode_media');
|
||||||
|
assert.equal(found?.caption, 'she said "hi" }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a null value is skipped in favour of a later real one', () => {
|
||||||
|
const source = `{"shortcode_media":null,"other":1,"wrap":{"shortcode_media":{"id":"7"}}}`;
|
||||||
|
assert.deepEqual(extractJsonObjectAfterKey(source, 'shortcode_media'), { id: '7' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a payload escaped inside a JS string is unescaped and decoded', () => {
|
||||||
|
// This is the real shape: Instagram nests the post JSON inside a bootstrap
|
||||||
|
// call, so the quotes and slashes arrive escaped a level deeper.
|
||||||
|
const source =
|
||||||
|
String.raw`requireLazy(["ServerJS"],function(s){s.handle({"gql_data":{\"shortcode_media\":{\"id\":\"7\",\"display_url\":\"https:\\\/\\\/cdn\\\/a.jpg\",\"caption\":\"line one\\nline two\"}}})})`;
|
||||||
|
const found = extractJsonObjectAfterKey<{ id: string; display_url: string; caption: string }>(
|
||||||
|
source,
|
||||||
|
'shortcode_media',
|
||||||
|
);
|
||||||
|
assert.equal(found?.id, '7');
|
||||||
|
// Both the slash escaping and the newline have to survive intact.
|
||||||
|
assert.equal(found?.display_url, 'https://cdn/a.jpg');
|
||||||
|
assert.equal(found?.caption, 'line one\nline two');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing key is undefined rather than a throw', () => {
|
||||||
|
assert.equal(extractJsonObjectAfterKey('{"a":1}', 'shortcode_media'), undefined);
|
||||||
|
assert.equal(extractJsonObjectAfterKey('', 'shortcode_media'), undefined);
|
||||||
|
assert.equal(extractJsonObjectAfterKey('{"shortcode_media":{"a":', 'shortcode_media'), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { mediaFromItem } from '../src/platforms/tiktok.ts';
|
||||||
|
import { fixture } from './helpers.ts';
|
||||||
|
|
||||||
|
test('a video post picks a playable file and carries the referrer the CDN demands', async () => {
|
||||||
|
const media = await mediaFromItem(fixture('tiktok/video.json'));
|
||||||
|
assert.equal(media.length, 1);
|
||||||
|
const [item] = media;
|
||||||
|
assert.equal(item?.kind, 'video');
|
||||||
|
assert.match(item?.url ?? '', /^https:\/\//);
|
||||||
|
// Without this header the CDN answers 403 and the player shows nothing.
|
||||||
|
assert.equal(item?.fetchHeaders?.['Referer'], 'https://www.tiktok.com/');
|
||||||
|
assert.ok(item?.kind === 'video' && item.poster, 'expected a cover frame');
|
||||||
|
assert.ok(item?.width && item.height);
|
||||||
|
assert.ok(item?.kind === 'video' && item.durationSec);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the highest bitrate rendition wins', async () => {
|
||||||
|
const media = await mediaFromItem({
|
||||||
|
video: {
|
||||||
|
playAddr: 'https://cdn/fallback.mp4',
|
||||||
|
bitrateInfo: [
|
||||||
|
{ Bitrate: 500_000, PlayAddr: { UrlList: ['https://cdn/low.mp4'] } },
|
||||||
|
{ Bitrate: 2_000_000, PlayAddr: { UrlList: ['https://cdn/high.mp4'] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(media[0]?.url, 'https://cdn/high.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a photo post yields images rather than an empty video', async () => {
|
||||||
|
const media = await mediaFromItem({
|
||||||
|
imagePost: {
|
||||||
|
images: [
|
||||||
|
{ imageURL: { UrlList: ['https://cdn/1.jpg'] }, imageWidth: 1080, imageHeight: 1350 },
|
||||||
|
{ imageURL: { UrlList: ['https://cdn/2.jpg'] }, imageWidth: 1080, imageHeight: 1350 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
video: { cover: 'https://cdn/cover.jpg' },
|
||||||
|
});
|
||||||
|
assert.equal(media.length, 2);
|
||||||
|
assert.ok(media.every((m) => m.kind === 'image'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { originalUrlFor, platformByPrefix, platformForUrl, stripTracking } from '../src/platforms/index.ts';
|
||||||
|
|
||||||
|
function rebuild(prefix: string, pathRest: string, search = ''): string {
|
||||||
|
const spec = platformByPrefix(prefix);
|
||||||
|
assert.ok(spec, `no platform for /${prefix}`);
|
||||||
|
return originalUrlFor(spec, pathRest, search);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('each prefix rebuilds its canonical original URL', () => {
|
||||||
|
assert.equal(rebuild('x', 'user/status/123'), 'https://x.com/user/status/123');
|
||||||
|
assert.equal(rebuild('ig', 'p/ABC123/'), 'https://www.instagram.com/p/ABC123/');
|
||||||
|
assert.equal(rebuild('threads', '@user/post/XYZ'), 'https://www.threads.com/@user/post/XYZ');
|
||||||
|
assert.equal(rebuild('bsky', 'profile/a.bsky.social/post/abc'),
|
||||||
|
'https://bsky.app/profile/a.bsky.social/post/abc');
|
||||||
|
assert.equal(rebuild('tiktok', '@user/video/789'), 'https://www.tiktok.com/@user/video/789');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bare TikTok code rebuilds as a share link, not a broken post path', () => {
|
||||||
|
// `vm.tiktok.com/ZGJabc/` loses its subdomain in the rewrite, so a single
|
||||||
|
// opaque segment has to be recognised as a share code.
|
||||||
|
assert.equal(rebuild('tiktok', 'ZGJabc123/'), 'https://vm.tiktok.com/ZGJabc123/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracking parameters are dropped, real ones are kept', () => {
|
||||||
|
assert.equal(rebuild('ig', 'p/ABC/', '?igsh=Zm9v&utm_source=ig_web'), 'https://www.instagram.com/p/ABC/');
|
||||||
|
assert.equal(rebuild('x', 'user/status/1', '?s=20&t=abc'), 'https://x.com/user/status/1');
|
||||||
|
assert.equal(rebuild('bsky', 'profile/a/post/b', '?foo=bar'), 'https://bsky.app/profile/a/post/b?foo=bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fragments are dropped so the cache does not split on them', () => {
|
||||||
|
assert.equal(stripTracking(new URL('https://x.com/a/status/1#top')).href, 'https://x.com/a/status/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pasted URL routes to its platform, including the old hostnames', () => {
|
||||||
|
assert.equal(platformForUrl('https://twitter.com/a/status/1')?.id, 'x');
|
||||||
|
assert.equal(platformForUrl('https://mobile.x.com/a/status/1')?.id, 'x');
|
||||||
|
assert.equal(platformForUrl('https://www.threads.net/@a/post/b')?.id, 'threads');
|
||||||
|
assert.equal(platformForUrl('https://vm.tiktok.com/ZGJabc/')?.id, 'tiktok');
|
||||||
|
assert.equal(platformForUrl('https://example.com/nope'), undefined);
|
||||||
|
assert.equal(platformForUrl('not a url'), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { mediaFromDetails, toPost } from '../src/platforms/x.ts';
|
||||||
|
import { fixture } from './helpers.ts';
|
||||||
|
|
||||||
|
const URL_ = 'https://x.com/example/status/1';
|
||||||
|
|
||||||
|
test('a text-only post carries no media', () => {
|
||||||
|
const post = toPost(fixture('x/text-only.json'), URL_);
|
||||||
|
assert.equal(post.platform, 'x');
|
||||||
|
assert.equal(post.textPosition, 'above');
|
||||||
|
assert.deepEqual(post.media, []);
|
||||||
|
assert.equal(post.author.handle, '@jack');
|
||||||
|
assert.ok(post.postedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a photo post keeps every photo and asks for the original size', () => {
|
||||||
|
const post = toPost(fixture('x/photo.json'), URL_);
|
||||||
|
assert.ok(post.media.length > 1, 'expected more than one photo');
|
||||||
|
assert.ok(post.media.every((m) => m.kind === 'image'));
|
||||||
|
assert.ok(post.media.every((m) => m.url.endsWith('?name=orig')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a video picks the highest-bitrate mp4 and ignores the streaming variants', () => {
|
||||||
|
const media = mediaFromDetails([
|
||||||
|
{
|
||||||
|
type: 'video',
|
||||||
|
media_url_https: 'https://pbs.twimg.com/poster.jpg',
|
||||||
|
original_info: { w: 1280, h: 720 },
|
||||||
|
video_info: {
|
||||||
|
duration_millis: 30_500,
|
||||||
|
variants: [
|
||||||
|
{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/p.m3u8' },
|
||||||
|
{ content_type: 'video/mp4', bitrate: 832_000, url: 'https://video.twimg.com/low.mp4' },
|
||||||
|
{ content_type: 'video/mp4', bitrate: 2_176_000, url: 'https://video.twimg.com/high.mp4' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(media, [
|
||||||
|
{
|
||||||
|
kind: 'video',
|
||||||
|
url: 'https://video.twimg.com/high.mp4',
|
||||||
|
poster: { url: 'https://pbs.twimg.com/poster.jpg' },
|
||||||
|
durationSec: 31,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a quote post shows the media of the post it quotes', () => {
|
||||||
|
// The quote itself carries none; without this the page is text and nothing
|
||||||
|
// else, which is exactly the thing the reader wanted to see.
|
||||||
|
const post = toPost(
|
||||||
|
{
|
||||||
|
text: 'look at this',
|
||||||
|
user: { screen_name: 'someone' },
|
||||||
|
mediaDetails: [],
|
||||||
|
quoted_tweet: {
|
||||||
|
mediaDetails: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/q.jpg' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
URL_,
|
||||||
|
);
|
||||||
|
assert.equal(post.media.length, 1);
|
||||||
|
assert.equal(post.media[0]?.url, 'https://pbs.twimg.com/q.jpg?name=orig');
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["es2023", "dom", "dom.iterable"],
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
|
||||||
|
// Source imports carry the real `.ts` specifier so `node --experimental-
|
||||||
|
// strip-types` can run the tree directly for dev, tests and the resolve
|
||||||
|
// CLI. tsc rewrites them to `.js` on the way into dist.
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "test", "bin"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user