diff --git a/CLAUDE.md b/CLAUDE.md
index 4ecd53a..ff4ea07 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,6 +62,10 @@ Request → `src/routes/post.ts` → `src/platforms/index.ts` maps prefix to ada
Adding a platform is one file in `src/platforms/` plus one row in the table in
`index.ts`. Everything downstream already handles a `Post`.
+A `Post` is a list of `Segment`s, not a single body. Most platforms produce one
+(`oneSegment` in `types.ts`); Bluesky and Threads produce the author's whole chain,
+with `isAnchor` marking the post that was linked — which need not be the first.
+
Things worth knowing before editing:
- **One Chromium, one context, persistent.** Cookies and dismissed banners accumulate on
@@ -88,7 +92,9 @@ Things worth knowing before editing:
- **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.
+ deliberately matches only V1. Threads are built by walking `parent` up and the
+ author's own `replies` down; `depth`/`parentHeight` are what make that possible, at
+ the cost of dragging the whole reply tree along (a few hundred KB on a busy post).
- **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
@@ -102,7 +108,12 @@ Things worth knowing before editing:
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.
+ candidates in it. The page ships the linked post, the author's follow-ups, other
+ people's replies and unrelated recommendations all as flat `thread_items`
+ containers. A follow-up is the author replying to *themselves*, which is what
+ separates it from a stranger's reply carrying the same `reply_to_author`. The first
+ post of a chain replies to nothing, so it is only reachable by walking backwards
+ from the one that answers it.
## Verification puzzles
diff --git a/README.md b/README.md
index 6171e78..77027b0 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,12 @@ Each adapter layers its extraction, most structured first:
| TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
| Threads | the post page | the Relay payloads in `'}
`.value,
@@ -42,13 +42,19 @@ function post(overrides: Partial = {}): Post {
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' }],
+ segments: oneSegment({
+ text: 'caption',
+ media: [{ kind: 'image', url: 'https://cdn/1.jpg' }, { kind: 'image', url: 'https://cdn/2.jpg' }],
+ }),
...overrides,
};
}
+function withMedia(media: Media[], overrides: Partial = {}): Post {
+ return post({ segments: oneSegment({ text: 'caption', media }), ...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');
@@ -56,9 +62,9 @@ test('media is proxied, never linked straight at the CDN', () => {
});
test('an HLS video is linked directly, because a proxy cannot rewrite a playlist', () => {
- const page = renderPost(post({
- media: [{ kind: 'video', url: 'https://video.bsky.app/x/playlist.m3u8', hls: true, direct: true }],
- }));
+ const page = renderPost(withMedia([
+ { 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'));
});
@@ -72,12 +78,12 @@ test('text sits below the media for Instagram and above it for X', () => {
test('the layout toggle only appears when there is more than one item', () => {
assert.ok(renderPost(post()).includes('data-view="grid"'));
- assert.ok(!renderPost(post({ media: [{ kind: 'image', url: 'https://cdn/1.jpg' }] }))
+ assert.ok(!renderPost(withMedia([{ kind: 'image', url: 'https://cdn/1.jpg' }]))
.includes('data-view="grid"'));
});
test('a video gets native controls and a source, not an iframe', () => {
- const page = renderPost(post({ media: [{ kind: 'video', url: 'https://cdn/v.mp4' }] }));
+ const page = renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }]));
assert.ok(page.includes('