Add Reddit with its comment threads, and show quoted posts whole
CI / Typecheck, test, build (pull_request) Successful in 9s

Two changes. They share the `Segment` model, which is why they arrive
together.

## Reddit

A new adapter under /reddit, plus the thread beneath the post -- on Reddit the
conversation is usually the reason the link was shared, so a viewer that showed
only the post would be showing the wrong half.

The `.json` twin of a post URL is the post and the whole first page of comments
in one response, far better than anything the page gives up, so it is the only
layer that normally runs. Reddit refuses it to a browser it has never seen and
answers with a JavaScript challenge, which any ordinary navigation solves by
itself; the adapter navigates once and retries, and the cookie left behind
serves every later post. Below that, `shreddit-comment` elements are read from
the rendered page -- flat, each carrying its own `depth`, so `treeFromDepths`
rebuilds the nesting.

`Post.comments` is a tree rather than a flat list with depths, because folding
a comment has to take everything under it along and nesting is what makes that
free. Each comment renders as a `<details open>`, so collapsing works with the
stylesheet off and from the keyboard, and a collapsed one says how many replies
it is hiding. "Collapse all" is the only part that needs the script, so it
ships hidden and appears once the script has run. What sits behind a "load
more" is not fetched -- that is a second page and often a third -- but it is
counted and said out loud rather than quietly dropped.

Four things the payloads got wrong on the first try, each now with a fixture:
`fallback_url` is the video track alone whenever `has_audio` is true, so a post
with sound has to use `hls_url` and only a silent one gets the proxied MP4;
`scrubber_media_url` looks like a poster and is a second MP4 for the timeline
thumbnails, while the still is in `preview.images`; a gallery's pictures live
in `media_metadata` keyed and unordered, with their order only in
`gallery_data`; and `replies` is the string "" rather than an object when there
are none. Comment bodies are Markdown, rendered by a new render/markdown.ts
that escapes first and then puts back only the constructs we chose to support
-- never Reddit's own `body_html`, which would mean trusting markup a stranger
caused to be generated.

An app share link (/r/<sub>/s/<code>) is a plain 301, so one request told not
to follow it is enough. The permalink it resolves to is what the copy button
hands back, since an opaque share code is a tracking parameter by another name.

## Quoted posts

Both X and Bluesky lifted the quoted post's media out and showed it as the
quoter's own, dropping the quoted words and the quoted author entirely. A quote
of a photo post therefore rendered as somebody else's picture under the wrong
name with nothing to say so, and a quote that had a picture of its own dropped
the quoted one instead -- the two could never both appear. Half the quote posts
people share are someone answering a stranger and the other half are someone
continuing a thought from an earlier post; neither reads with only one side of
it on the page.

`Segment.quoted` now carries the whole thing -- author, words, pictures, time
and a link to it -- and renders as a post inside the post. Neither payload
carries a usable address for it: X has no permalink and it is rebuilt from the
handle and `id_str`, Bluesky has an `at://` URI nobody can open and it is
rebuilt from the handle and the record key. On Bluesky the record sits at
`embed.record` for a plain quote and at `embed.record.record` when the quoting
post has media of its own, and a quote can also point at a feed, a list or a
post since deleted, which arrive in the same slot under a different `$type` --
only `app.bsky.embed.record#viewRecord` is taken.

Two things about X's text, both visible on any post and not only a quote. It
arrives pre-escaped, so an ampersand someone typed was reaching the page as the
literal `&amp;`; it is decoded in the adapter, where the encoding comes from,
leaving the escape-on-the-way-out rule alone. And every link is a `t.co`, which
tells the reader nothing and routes them through X's click tracker to find out
-- `entities.urls` carries the real address alongside, so it is put back. The
shortlink X staples onto the end of a quote post is dropped rather than
expanded, since the post it points at is already on the page;
`display_text_range` is where that boundary is and it keeps a link the author
put there deliberately. Its indices are UTF-16 units into the escaped text, so
the slice happens before decoding and before expanding, and splitting to
codepoints first overshoots past an emoji -- checked against a post carrying
one.

A quote is context, and context that fills the screen has stopped being
context, so a segment carrying one gives up the window-filling cap on its own
media.

## Along the way

setupMedia only ever wired `document.querySelector('.media')`, the first rail
on the page. That was already wrong for a Bluesky or Threads chain with media
in more than one post, and a quoted carousel would have hit it too. It now runs
per rail.

## Verified

108 tests, typecheck and build clean. Resolved end to end against the live
platforms: Reddit self, gallery, video, link and share-link posts; X quotes
with no media, with media on the quoted side, and with media on both; Bluesky
quotes in both embed shapes, including a ten-post chain where every post quotes
a different account and all nine quotes come back under the right name.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
This commit is contained in:
2026-08-27 11:45:40 -03:00
co-authored by Claude Opus 5
parent 361cdbd966
commit 6325f0ff32
28 changed files with 3623 additions and 69 deletions
+55 -8
View File
@@ -1,5 +1,5 @@
import { config } from '../config.ts';
import { ResolveError, type Media, type Post, type Segment } from '../types.ts';
import { ResolveError, type Media, type Post, type Quoted, type Segment } from '../types.ts';
import { captureJson } from '../browser/capture.ts';
import { hostMatcher } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts';
@@ -8,6 +8,18 @@ const API = 'https://public.api.bsky.app/xrpc';
type Aspect = { width: number; height: number };
type Actor = { handle?: string; displayName?: string; avatar?: string };
/** The quoted post as the API hands it over, when it is a post at all — a
* quote can also point at a feed, a list, or something deleted. */
type ViewRecord = {
$type?: string;
uri?: string;
author?: Actor;
value?: { text?: string; createdAt?: string };
embeds?: Embed[];
};
type Embed = {
$type?: string;
images?: Array<{ thumb?: string; fullsize?: string; alt?: string; aspectRatio?: Aspect }>;
@@ -15,12 +27,14 @@ type Embed = {
thumbnail?: string;
aspectRatio?: Aspect;
media?: Embed;
record?: { embeds?: Embed[] };
/** A plain quote puts the record here; a quote with media of its own puts
* it one level further in. */
record?: ViewRecord & { record?: ViewRecord };
};
type BskyPost = {
uri?: string;
author?: { handle?: string; displayName?: string; avatar?: string };
author?: Actor;
record?: { text?: string; createdAt?: string };
embed?: Embed;
indexedAt?: string;
@@ -73,19 +87,52 @@ export function mediaFromEmbed(embed: Embed | undefined): Media[] {
}];
}
// 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);
// Nothing of its own. A quoted post's pictures belong to the quoted post
// and are shown with it, under the name of whoever actually posted them.
return [];
}
/** `at://<did>/app.bsky.feed.post/<rkey>` is not a link anyone can open, so
* the web address is rebuilt from the handle and the record key. */
function webUrlFor(uri: string | undefined, handle: string | undefined): string | undefined {
const rkey = uri?.split('/').pop();
return rkey && handle ? `https://bsky.app/profile/${handle}/post/${rkey}` : undefined;
}
/**
* The post being quoted.
*
* A quote can also point at a feed, a list or a post that has since been
* deleted or blocked, and those arrive under different `$type`s in the same
* slot. Only a real post is taken; anything else is left out rather than
* rendered as a nameless empty box.
*/
export function quotedFrom(embed: Embed | undefined): Quoted | undefined {
// A quote carrying media of its own nests the record one level deeper.
const record = embed?.record?.record ?? embed?.record;
if (record?.$type !== 'app.bsky.embed.record#viewRecord' || !record.author) return undefined;
const url = webUrlFor(record.uri, record.author.handle);
return {
author: {
handle: `@${record.author.handle ?? 'unknown'}`,
...(record.author.displayName ? { displayName: record.author.displayName } : {}),
...(record.author.avatar ? { avatar: { url: record.author.avatar } } : {}),
},
...(record.value?.text ? { text: record.value.text } : {}),
media: mediaFromEmbed(record.embeds?.[0]),
...(record.value?.createdAt ? { postedAt: record.value.createdAt } : {}),
...(url ? { url } : {}),
};
}
function segmentOf(post: BskyPost, isAnchor: boolean): Segment {
const quoted = quotedFrom(post.embed);
return {
...(post.record?.text ? { text: post.record.text } : {}),
media: mediaFromEmbed(post.embed),
...(post.record?.createdAt ? { postedAt: post.record.createdAt } : {}),
...(quoted ? { quoted } : {}),
...(isAnchor ? { isAnchor: true } : {}),
};
}
+2 -1
View File
@@ -5,8 +5,9 @@ import { threads } from './threads.ts';
import { instagram } from './instagram.ts';
import { tiktok } from './tiktok.ts';
import { bluesky } from './bluesky.ts';
import { reddit } from './reddit.ts';
export const platforms: readonly PlatformSpec[] = [x, threads, instagram, tiktok, bluesky];
export const platforms: readonly PlatformSpec[] = [x, threads, instagram, tiktok, bluesky, reddit];
const byPrefix = new Map(platforms.map((p) => [p.prefix, p]));
+466
View File
@@ -0,0 +1,466 @@
import type { Page } from 'playwright';
import { ResolveError, oneSegment, type Comment, type Media, type Post } from '../types.ts';
import { readOpenGraph } from '../browser/capture.ts';
import { stripTracking } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts';
const HOST = 'https://www.reddit.com';
/** Path roots that are really Reddit paths, so anything else that arrives as
* a single opaque segment is a `redd.it` share code. */
const KNOWN_ROOTS = new Set(['r', 'u', 'user', 'comments', 'gallery', 'media', 'wiki']);
type Listing<T> = { kind?: string; data?: { children?: Array<Thing<T>> } };
type Thing<T> = { kind?: string; data?: T };
type RedditVideo = {
fallback_url?: string;
hls_url?: string;
has_audio?: boolean;
width?: number;
height?: number;
duration?: number;
};
type MediaMeta = {
status?: string;
e?: string;
m?: string;
s?: { u?: string; gif?: string; mp4?: string; x?: number; y?: number };
};
type Link = {
title?: string;
author?: string;
subreddit_name_prefixed?: string;
created_utc?: number;
selftext?: string;
permalink?: string;
url?: string;
url_overridden_by_dest?: string;
domain?: string;
post_hint?: string;
is_self?: boolean;
is_video?: boolean;
is_gallery?: boolean;
over_18?: boolean;
spoiler?: boolean;
link_flair_text?: string | null;
num_comments?: number;
score?: number;
gallery_data?: { items?: Array<{ media_id?: string }> };
media_metadata?: Record<string, MediaMeta>;
secure_media?: { reddit_video?: RedditVideo } | null;
media?: { reddit_video?: RedditVideo } | null;
preview?: {
images?: Array<{ source?: { url?: string; width?: number; height?: number } }>;
reddit_video_preview?: RedditVideo;
};
crosspost_parent_list?: Link[];
};
type CommentData = {
author?: string;
body?: string;
created_utc?: number;
score?: number;
score_hidden?: boolean;
is_submitter?: boolean;
distinguished?: string | null;
replies?: Listing<CommentData> | '';
};
type MoreData = { count?: number };
/** `[deleted]` and `[removed]` are the platform's own words, kept as they are. */
function authorName(author: string | undefined): string {
if (!author || author.startsWith('[')) return author ?? '[unknown]';
return `u/${author}`;
}
function isoFrom(seconds: number | undefined): string | undefined {
return seconds ? new Date(seconds * 1000).toISOString() : undefined;
}
function sized(width: number | undefined, height: number | undefined) {
return {
...(width ? { width } : {}),
...(height ? { height } : {}),
};
}
/**
* Reddit's own video, which arrives in three forms at once.
*
* `fallback_url` is a plain MP4 and the nicer thing to serve — it proxies,
* so the viewer never talks to Reddit, and the native scrubber can seek it.
* But when a post has sound that MP4 is the video track alone: the audio is
* a separate file the DASH manifest stitches back on. So a silent post gets
* the MP4 and one with audio gets the HLS playlist, linked directly because
* proxying it would mean rewriting the manifest and every segment.
*/
function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[] {
const common = {
...sized(video.width, video.height),
...(video.duration ? { durationSec: video.duration } : {}),
};
if (video.has_audio !== false && video.hls_url) {
return [{ kind: 'video', url: video.hls_url, hls: true, direct: true, ...common }];
}
const url = video.fallback_url ?? video.hls_url;
if (!url) return [];
if (url === video.hls_url) {
return [{ kind: 'video', url, hls: true, direct: true, ...common }];
}
return [{
kind: 'video',
url,
...(poster ? { poster: { url: poster } } : {}),
...common,
}];
}
/** One entry of a gallery post. Reddit keeps the pictures somewhere other
* than the order it wants them shown in, so the two have to be joined. */
function fromGalleryItem(meta: MediaMeta | undefined): Media[] {
if (!meta || meta.status !== 'valid') return [];
const size = sized(meta.s?.x, meta.s?.y);
// An animated entry ships as a video as well; that is smaller and seekable.
if (meta.s?.mp4) return [{ kind: 'video', url: meta.s.mp4, ...size }];
const url = meta.s?.gif ?? meta.s?.u;
return url ? [{ kind: 'image', url, ...size }] : [];
}
export function mediaFromLink(link: Link): Media[] {
// A crosspost is a wrapper with nothing of its own in it.
const crossposted = link.crosspost_parent_list?.[0];
if (crossposted && !link.is_self) {
const inner = mediaFromLink(crossposted);
if (inner.length) return inner;
}
// `scrubber_media_url` looks like a poster and is not one: it is a second,
// tiny MP4 for the timeline thumbnails. The still is in the preview.
const still = link.preview?.images?.[0]?.source?.url;
const video = link.secure_media?.reddit_video ?? link.media?.reddit_video;
if (video) return fromRedditVideo(video, still);
if (link.is_gallery) {
const items = link.gallery_data?.items ?? [];
const gallery = items.flatMap((item) =>
fromGalleryItem(item.media_id ? link.media_metadata?.[item.media_id] : undefined));
if (gallery.length) return gallery;
}
// A GIF link is served as a video by the preview, which is the version
// worth having: the original is often tens of megabytes.
const asVideo = link.preview?.reddit_video_preview;
if (asVideo) return fromRedditVideo(asVideo, still);
const direct = link.url_overridden_by_dest ?? link.url;
if (direct && /\.(jpe?g|png|webp|gif)(\?|$)/i.test(direct)) {
const source = link.preview?.images?.[0]?.source;
return [{ kind: 'image', url: direct, ...sized(source?.width, source?.height) }];
}
// A link to somewhere else: the preview is all Reddit has, and it is still
// worth showing above the link itself.
const preview = link.preview?.images?.[0]?.source;
if (preview?.url) {
return [{ kind: 'image', url: preview.url, ...sized(preview.width, preview.height) }];
}
return [];
}
/**
* The words of the post: the self text, or — for a link post — the
* destination, which is the whole content of the post and needs to be
* tappable.
*/
function bodyOf(link: Link): string | undefined {
if (link.selftext?.trim()) return link.selftext;
const dest = link.url_overridden_by_dest;
if (dest && !dest.includes('redd.it') && !dest.startsWith(`${HOST}/gallery/`)) return dest;
return undefined;
}
export function commentsFrom(listing: Listing<CommentData> | undefined): {
comments: Comment[];
more: number;
} {
const comments: Comment[] = [];
let more = 0;
for (const child of listing?.data?.children ?? []) {
if (child.kind === 'more') {
more += (child.data as MoreData | undefined)?.count ?? 0;
continue;
}
const data = child.data;
if (!data || data.body === undefined) continue;
const replies = data.replies && typeof data.replies === 'object'
? commentsFrom(data.replies)
: { comments: [], more: 0 };
comments.push({
author: authorName(data.author),
...(data.body ? { text: data.body } : {}),
...(isoFrom(data.created_utc) ? { postedAt: isoFrom(data.created_utc) } : {}),
// Reddit hides the score on a new comment so an early downvote cannot
// steer the rest. Showing a placeholder 1 would be a lie.
...(data.score_hidden !== true && typeof data.score === 'number' ? { score: data.score } : {}),
...(data.is_submitter ? { isAuthor: true } : {}),
...(data.distinguished ? { distinguished: data.distinguished } : {}),
replies: replies.comments,
...(replies.more ? { moreReplies: replies.more } : {}),
});
}
return { comments, more };
}
export function toPost(body: [Listing<Link>, Listing<CommentData>], originalUrl: string): Post {
const link = body[0]?.data?.children?.[0]?.data;
if (!link) throw new ResolveError('Reddit returned no post', 'reddit', originalUrl);
const { comments, more } = commentsFrom(body[1]);
const flags = [
link.link_flair_text?.trim(),
link.over_18 ? 'NSFW' : undefined,
link.spoiler ? 'Spoiler' : undefined,
].filter(Boolean);
return {
platform: 'reddit',
platformLabel: 'Reddit',
originalUrl,
author: {
// The subreddit is the identity a Reddit link carries — the poster is
// a detail underneath it, so it goes in the display name.
handle: link.subreddit_name_prefixed ?? 'reddit',
displayName: [authorName(link.author), ...flags].join(' · '),
},
textPosition: 'above',
segments: oneSegment({
...(link.title ? { title: link.title } : {}),
...(bodyOf(link) ? { text: bodyOf(link) } : {}),
media: mediaFromLink(link),
...(isoFrom(link.created_utc) ? { postedAt: isoFrom(link.created_utc) } : {}),
}),
comments,
...(more ? { moreComments: more } : {}),
...(typeof link.num_comments === 'number' ? { commentCount: link.num_comments } : {}),
};
}
export type FlatComment = {
depth: number;
author: string;
score: number;
created: string;
text: string;
};
/**
* Rebuild the nesting from a flat list of comments that each know only their
* own depth, which is how the rendered page presents them.
*/
export function treeFromDepths(flat: FlatComment[]): Comment[] {
const roots: Comment[] = [];
// The comment last seen at each depth, so a reply can be attached to
// whatever it appeared underneath.
const openAt: Comment[] = [];
for (const row of flat) {
if (!row.text) continue;
const comment: Comment = {
author: authorName(row.author),
text: row.text,
...(row.created && !Number.isNaN(Date.parse(row.created))
? { postedAt: new Date(row.created).toISOString() }
: {}),
...(Number.isFinite(row.score) ? { score: row.score } : {}),
replies: [],
};
const parent = openAt[row.depth - 1];
if (row.depth > 0 && parent) parent.replies.push(comment);
else roots.push(comment);
// Anything deeper belonged to the comment this one just replaced.
openAt.length = row.depth + 1;
openAt[row.depth] = comment;
}
return roots;
}
/**
* Reads the comment tree back out of the rendered page.
*
* The floor, for when the JSON is refused. Reddit renders every comment as a
* `<shreddit-comment>` carrying its own depth, flat in document order.
*/
async function commentsFromDom(page: Page): Promise<Comment[]> {
const flat = await page
.evaluate(() =>
[...document.querySelectorAll('shreddit-comment')].map((el) => ({
depth: Number(el.getAttribute('depth') ?? 0),
author: el.getAttribute('author') ?? '',
score: Number(el.getAttribute('score') ?? Number.NaN),
created: el.getAttribute('created') ?? '',
text: el.querySelector('[slot="comment"]')?.textContent?.trim() ?? '',
})))
.catch((): FlatComment[] => []);
return treeFromDepths(flat);
}
/** Whatever the page will admit to, when the JSON will not come. */
async function fromPage(page: Page, originalUrl: string): Promise<Post> {
const attrs = await page
.evaluate(() => {
const post = document.querySelector('shreddit-post');
if (!post) return null;
const get = (name: string): string | undefined => post.getAttribute(name) ?? undefined;
return {
title: get('post-title'),
author: get('author'),
subreddit: get('subreddit-prefixed-name'),
created: get('created-timestamp'),
score: get('score'),
comments: get('comment-count'),
image: get('content-href'),
};
})
.catch(() => null);
const og = await readOpenGraph(page);
const title = attrs?.title ?? og['og:title'];
if (!title) throw new ResolveError('Reddit served neither the post nor its data', 'reddit', originalUrl);
const image = attrs?.image ?? og['og:image'];
const media: Media[] =
image && /\.(jpe?g|png|webp|gif)(\?|$)/i.test(image) ? [{ kind: 'image', url: image }] : [];
const count = Number(attrs?.comments);
return {
platform: 'reddit',
platformLabel: 'Reddit',
originalUrl,
author: {
handle: attrs?.subreddit ?? 'reddit',
displayName: authorName(attrs?.author),
},
textPosition: 'above',
segments: oneSegment({
title,
media,
...(attrs?.created && !Number.isNaN(Date.parse(attrs.created))
? { postedAt: new Date(attrs.created).toISOString() }
: {}),
}),
comments: await commentsFromDom(page),
...(Number.isFinite(count) ? { commentCount: count } : {}),
};
}
/** The `.json` twin of a post URL, asked for at the size of one page. */
function jsonUrlFor(postUrl: string): string {
const url = new URL(postUrl);
url.pathname = `${url.pathname.replace(/\/+$/, '')}/.json`;
url.search = '';
url.searchParams.set('raw_json', '1');
url.searchParams.set('limit', '100');
url.searchParams.set('sort', 'confidence');
return url.href;
}
function isShareLink(url: URL): boolean {
return url.hostname === 'redd.it' || /^\/r\/[^/]+\/s\/[^/]+/.test(url.pathname);
}
/**
* Share links from the app are opaque, and the post id is only on the other
* side of a redirect. One request that is told not to follow it is enough —
* far cheaper than loading the page to find out where it went.
*/
async function followShareLink(page: Page, originalUrl: string): Promise<string> {
const response = await page.request
.get(originalUrl, { maxRedirects: 0 })
.catch(() => undefined);
const location = response?.headers()['location'];
if (!location) return originalUrl;
try {
return stripTracking(new URL(location, originalUrl)).href;
} catch {
return originalUrl;
}
}
async function fetchJson(page: Page, url: string): Promise<[Listing<Link>, Listing<CommentData>] | undefined> {
const response = await page.request
.get(url, { headers: { Accept: 'application/json' } })
.catch(() => undefined);
if (!response?.ok()) return undefined;
if (!response.headers()['content-type']?.includes('json')) return undefined;
const body = (await response.json().catch(() => undefined)) as unknown;
return Array.isArray(body) && body.length >= 2 ? (body as [Listing<Link>, Listing<CommentData>]) : undefined;
}
async function resolve({ page, originalUrl }: ResolveContext): Promise<Post> {
let postUrl = originalUrl;
try {
if (isShareLink(new URL(originalUrl))) postUrl = await followShareLink(page, originalUrl);
} catch {
// Not a URL we can take apart; the request below will say so.
}
// Reddit's JSON is the whole post and the whole first page of comments in
// one response, which no amount of reading the page can match.
let body = await fetchJson(page, jsonUrlFor(postUrl));
if (!body) {
// A cold context gets a JavaScript challenge instead, which the page
// solves by itself on any ordinary navigation. Doing that once leaves the
// cookie behind and the JSON answers from then on, including for every
// later post.
await page.goto(postUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
await page.waitForTimeout(1500);
body = await fetchJson(page, jsonUrlFor(page.url() || postUrl));
if (!body) {
// Still refused. The page in front of us is the post, so read that.
await page.waitForSelector('shreddit-comment', { timeout: 5_000 }).catch(() => undefined);
return fromPage(page, originalUrl);
}
}
// The resolved permalink, not the share code we came in with: an opaque
// `/s/` link says nothing about what it points at, and handing it back is
// the same as handing back a tracking parameter.
return toPost(body, postUrl);
}
export const reddit: PlatformSpec = {
id: 'reddit',
label: 'Reddit',
prefix: 'reddit',
canonicalHost: 'www.reddit.com',
textPosition: 'above',
matchesHost: (host) => host === 'reddit.com' || host.endsWith('.reddit.com') || host === 'redd.it',
/** A single opaque segment is a `redd.it` share code — the rewrite rule
* drops the host, so `redd.it/abc123` arrives as just `abc123`. */
buildOriginalUrl: (pathRest, search) => {
const first = pathRest.split('/')[0] ?? '';
if (first && !pathRest.includes('/') && !KNOWN_ROOTS.has(first)) {
return `https://redd.it/${first}${search}`;
}
return `${HOST}/${pathRest}${search}`;
},
resolve,
};
+1
View File
@@ -9,6 +9,7 @@ const TRACKING_PARAMS = new Set([
'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',
'share_id', 'rdt', 'correlation_id', 'post_fullname', 'ref_campaign',
]);
export function stripTracking(url: URL): URL {
+119 -17
View File
@@ -1,5 +1,5 @@
import { config } from '../config.ts';
import { ResolveError, oneSegment, type Media, type Post } from '../types.ts';
import { ResolveError, oneSegment, type Author, type Media, type Post, type Quoted } from '../types.ts';
import { captureJson } from '../browser/capture.ts';
import { hostMatcher } from './url.ts';
import type { PlatformSpec, ResolveContext } from './types.ts';
@@ -16,15 +16,89 @@ type MediaDetail = {
video_info?: { duration_millis?: number; variants?: VideoVariant[] };
};
type TweetUser = { screen_name?: string; name?: string; profile_image_url_https?: string };
type TweetResult = {
id_str?: string;
text?: string;
/** Which slice of `text` X itself shows. UTF-16 indices. */
display_text_range?: [number, number];
entities?: { urls?: Array<{ url?: string; expanded_url?: string }> };
created_at?: string;
user?: { screen_name?: string; name?: string; profile_image_url_https?: string };
user?: TweetUser;
mediaDetails?: MediaDetail[];
quoted_tweet?: TweetResult;
tombstone?: unknown;
};
/**
* X hands back post text with the markup-significant characters already
* escaped — an ampersand someone typed arrives as `&amp;`. Everything
* downstream escapes on the way out, so leaving them would put the entity
* itself on screen.
*/
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
};
function decodeEntities(text: string): string {
// `&amp;` last would double-decode `&amp;lt;`; one pass over the lot cannot.
return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => ENTITIES[entity] ?? entity);
}
const SHORTLINK = /https?:\/\/t\.co\/[A-Za-z0-9]+/g;
/**
* Put back the addresses `t.co` stands in for.
*
* Every link in a post is rewritten to a shortlink before it is stored, so
* left alone the page shows `t.co/QdJhOVu4En` — which says nothing about
* where it goes, and routes the reader through X's click tracker to find
* out. The payload carries the real address alongside, so there is no reason
* to show the stand-in.
*
* Matched on the shortlink itself rather than by `indices`, which are offsets
* into a string this function is in the middle of changing the length of.
*/
function expandShortlinks(text: string, result: TweetResult): string {
const expansions = new Map<string, string>();
for (const entity of result.entities?.urls ?? []) {
if (!entity.url || !entity.expanded_url) continue;
if (!/^https?:\/\//.test(entity.expanded_url)) continue;
expansions.set(entity.url, entity.expanded_url);
}
if (expansions.size === 0) return text;
return text.replace(SHORTLINK, (link) => expansions.get(link) ?? link);
}
/**
* The words of the post, as X itself would show them.
*
* A quote post gets a `t.co` link to the post it quotes stapled to the end of
* its text, and X hides it — the quoted post is on the page, so a shortlink
* to it is noise. `display_text_range` is where that boundary is, and it also
* keeps a link the author put there on purpose, which is why the range is
* used rather than stripping trailing URLs.
*
* Sliced before decoding: the indices are into the escaped text, where an
* ampersand is five characters. They are UTF-16 units, so a plain `slice` is
* right and splitting to codepoints first would overshoot past an emoji.
*/
function visibleText(result: TweetResult): string | undefined {
const raw = result.text;
if (!raw) return undefined;
const range = result.display_text_range;
const shown = Array.isArray(range) ? raw.slice(range[0], range[1]) : raw;
// Expanded after the slice for the same reason: the range is measured
// against the text as it arrived.
const text = expandShortlinks(decodeEntities(shown), result).trimEnd();
return text || undefined;
}
/** Every X post URL ends in `/status/<id>`, whatever precedes it. */
function tweetId(pathRest: string): string | undefined {
return /\/status(?:es)?\/(\d+)/.exec(`/${pathRest}`)?.[1];
@@ -75,29 +149,57 @@ export function mediaFromDetails(details: MediaDetail[] | undefined): Media[] {
});
}
function authorOf(user: TweetUser | undefined): Author {
const avatar = user?.profile_image_url_https;
return {
handle: `@${user?.screen_name ?? 'unknown'}`,
...(user?.name ? { displayName: user.name } : {}),
// `_normal` is a 48px thumbnail; `_x96` is the same file at 96.
...(avatar ? { avatar: { url: avatar.replace('_normal', '_x96') } } : {}),
};
}
/**
* The post a quote post is talking about.
*
* Shown in full — whose it is, what it said and what it showed — rather than
* having its picture lifted out and hung under the quoter's name. Half the
* quote posts people share are someone answering a stranger, and the other
* half are someone continuing their own thought from an earlier post; both
* are unreadable with only one side of them on the page.
*
* The syndication payload carries no permalink for the quoted post, so its
* URL is rebuilt from the handle and the id, the same shape every X post has.
*/
export function quotedFrom(quoted: TweetResult | undefined): Quoted | undefined {
if (!quoted?.user) return undefined;
const handle = quoted.user.screen_name;
return {
author: authorOf(quoted.user),
...(visibleText(quoted) ? { text: visibleText(quoted) } : {}),
media: mediaFromDetails(quoted.mediaDetails),
...(quoted.created_at ? { postedAt: quoted.created_at } : {}),
...(handle && quoted.id_str
? { url: `https://x.com/${handle}/status/${quoted.id_str}` }
: {}),
};
}
export function toPost(result: TweetResult, originalUrl: string): Post {
const handle = result.user?.screen_name ?? 'unknown';
const avatar = result.user?.profile_image_url_https;
const quoted = quotedFrom(result.quoted_tweet);
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') } } : {}),
},
author: authorOf(result.user),
textPosition: 'above',
segments: oneSegment({
...(result.text ? { text: result.text } : {}),
// 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,
),
...(visibleText(result) ? { text: visibleText(result) } : {}),
// Only its own. The quoted post's pictures are shown with the quoted
// post, where it is clear whose they are.
media: mediaFromDetails(result.mediaDetails),
...(result.created_at ? { postedAt: result.created_at } : {}),
...(quoted ? { quoted } : {}),
}),
};
}
+1
View File
@@ -31,6 +31,7 @@ const ACCENT: Record<string, { color: string; monogram: string }> = {
instagram: { color: '#c13584', monogram: 'IG' },
tiktok: { color: '#fe2c55', monogram: '♪' },
bluesky: { color: '#0085ff', monogram: 'B' },
reddit: { color: '#ff4500', monogram: 'r' },
};
export function badge(platform: string, label: string): Raw {
+167
View File
@@ -0,0 +1,167 @@
import { escapeHtml, raw, type Raw } from './html.ts';
/**
* The small subset of Markdown people actually write in a Reddit comment.
*
* Not a Markdown implementation, and not trying to be one. Reddit hands us
* `body_html` too, but taking that would mean trusting markup a stranger
* caused to be generated; the rule here is the same as everywhere else in
* this project — escape first, then put back only the constructs we chose to
* support. Anything unrecognised stays as the literal characters the
* commenter typed, which is the safe way to be wrong.
*/
const REDDIT = 'https://www.reddit.com';
/** Absolute http(s) only. `javascript:` and friends never become links. */
function safeHref(url: string): string | undefined {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : undefined;
} catch {
return undefined;
}
}
function anchor(href: string, label: string): string {
return `<a href="${escapeHtml(href)}" rel="noopener noreferrer nofollow" target="_blank">${escapeHtml(label)}</a>`;
}
/**
* Trailing punctuation is far more often the end of the sentence than part
* of the address, and an unmatched closing bracket cannot belong to a URL
* that has no opening one.
*/
function trimUrlTail(url: string): string {
let out = url;
for (;;) {
const last = out.at(-1) ?? '';
if (',.;:!?'.includes(last)) out = out.slice(0, -1);
else if (last === ')' && (out.match(/\(/g)?.length ?? 0) < (out.match(/\)/g)?.length ?? 0)) {
out = out.slice(0, -1);
} else return out;
}
}
const INLINE = new RegExp(
[
'`([^`\\n]+)`', // 1 code
'\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 2 label, 3 href
'\\*\\*([^*\\n]+)\\*\\*', // 4 strong
'~~([^~\\n]+)~~', // 5 strike
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 6 em with asterisks
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 7 em with underscores
'(https?://[^\\s<>]+)', // 8 bare url
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 9 r/sub and u/name
].join('|'),
'g',
);
/** One line of body text: escaped, with the inline constructs put back. */
function inline(text: string): string {
let out = '';
let cursor = 0;
for (const match of text.matchAll(INLINE)) {
const [whole, code, label, href, strong, strike, emStar, emScore, url, subOrUser] = match;
out += escapeHtml(text.slice(cursor, match.index));
cursor = match.index + whole.length;
if (code !== undefined) {
out += `<code>${escapeHtml(code)}</code>`;
} else if (label !== undefined && href !== undefined) {
const safe = safeHref(href);
out += safe ? anchor(safe, label) : escapeHtml(whole);
} else if (strong !== undefined) {
out += `<strong>${inline(strong)}</strong>`;
} else if (strike !== undefined) {
out += `<del>${inline(strike)}</del>`;
} else if (emStar !== undefined || emScore !== undefined) {
out += `<em>${inline(emStar ?? emScore ?? '')}</em>`;
} else if (url !== undefined) {
const trimmed = trimUrlTail(url);
const safe = safeHref(trimmed);
out += safe
? anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + escapeHtml(url.slice(trimmed.length))
: escapeHtml(whole);
} else if (subOrUser !== undefined) {
const path = subOrUser.startsWith('/') ? subOrUser : `/${subOrUser}`;
out += anchor(`${REDDIT}${path}`, subOrUser);
}
}
return out + escapeHtml(text.slice(cursor));
}
const BULLET = /^\s{0,3}[-*+]\s+/;
const NUMBERED = /^\s{0,3}\d+[.)]\s+/;
/**
* Group lines into blocks and render each. Blocks are what separate a quote
* from the reply to it, and a comment that loses that separation reads as
* though the commenter said both halves.
*/
function blocks(lines: string[]): string {
let out = '';
let at = 0;
const takeWhile = (keep: (line: string) => boolean): string[] => {
const run: string[] = [];
while (at < lines.length && keep(lines[at] ?? '')) {
run.push(lines[at] ?? '');
at += 1;
}
return run;
};
while (at < lines.length) {
const line = lines[at] ?? '';
if (line.trim() === '') {
at += 1;
continue;
}
if (/^\s*```/.test(line)) {
at += 1;
const body = takeWhile((l) => !/^\s*```/.test(l));
if (at < lines.length) at += 1; // the closing fence
out += `<pre><code>${escapeHtml(body.join('\n'))}</code></pre>`;
continue;
}
if (/^ {4}\S/.test(line)) {
const body = takeWhile((l) => /^ {4}/.test(l) || l.trim() === '');
out += `<pre><code>${escapeHtml(body.map((l) => l.slice(4)).join('\n').trimEnd())}</code></pre>`;
continue;
}
if (/^\s*>/.test(line)) {
const body = takeWhile((l) => /^\s*>/.test(l));
// Nested, so a quote of a quote keeps its shape.
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')))}</blockquote>`;
continue;
}
if (BULLET.test(line) || NUMBERED.test(line)) {
const ordered = !BULLET.test(line);
const pattern = ordered ? NUMBERED : BULLET;
const items = takeWhile((l) => pattern.test(l));
const tag = ordered ? 'ol' : 'ul';
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''))}</li>`).join('')}</${tag}>`;
continue;
}
const paragraph = takeWhile(
(l) => l.trim() !== '' && !/^\s*>/.test(l) && !BULLET.test(l) && !NUMBERED.test(l) && !/^\s*```/.test(l),
);
out += `<p>${paragraph.map((l) => inline(l)).join('<br>')}</p>`;
}
return out;
}
/** Comment text, as safe markup. */
export function renderMarkdown(text: string): Raw {
return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n')));
}
+136 -5
View File
@@ -1,7 +1,8 @@
import { proxyUrlFor } from '../media/registry.ts';
import { anchorOf, type Media, type Post, type Segment } from '../types.ts';
import { anchorOf, type Comment, type Media, type Post, type Quoted, type Segment } from '../types.ts';
import { html, raw, type Raw } from './html.ts';
import { badge, layout, originalUrlBlock } from './layout.ts';
import { renderMarkdown } from './markdown.ts';
import { linkify } from './text.ts';
/**
@@ -62,8 +63,11 @@ function renderMedia(media: Media[]): Raw {
}
function renderText(segment: Segment, post: Post): Raw {
if (!segment.text) return html``;
return html`<div class="text">${linkify(segment.text, post.platform)}</div>`;
if (!segment.text && !segment.title) return html``;
return html`<div class="text">
${segment.title ? html`<h1 class="text__title">${segment.title}</h1>` : ''}
${segment.text ? linkify(segment.text, post.platform) : ''}
</div>`;
}
function renderWhen(postedAt: string | undefined): Raw {
@@ -76,6 +80,36 @@ function renderWhen(postedAt: string | undefined): Raw {
})}</time>`;
}
/**
* The post being quoted, drawn as a post of its own inside the one quoting
* it: its author, its words, its pictures, and a way to open it.
*
* The author line is the point. Lifting only the media out — which is what
* this used to do — put someone else's picture under the quoter's name with
* nothing to say so.
*/
function renderQuoted(quoted: Quoted, post: Post): Raw {
const avatar = quoted.author.avatar ? proxyUrlFor(quoted.author.avatar) : undefined;
return html`<blockquote class="quote">
<header class="quote__who">
${avatar ? html`<img class="quote__avatar" src="${avatar}" alt="" loading="lazy">` : ''}
${quoted.author.displayName
? html`<span class="quote__name">${quoted.author.displayName}</span>`
: ''}
<span class="quote__handle">${quoted.author.handle}</span>
${renderWhen(quoted.postedAt)}
</header>
${quoted.text ? html`<div class="quote__text">${linkify(quoted.text, post.platform)}</div>` : ''}
${renderMedia(quoted.media)}
${quoted.url
? html`<a class="quote__open" href="${quoted.url}" rel="noopener noreferrer nofollow" target="_blank">
Open the quoted post on ${post.platformLabel}
</a>`
: ''}
</blockquote>`;
}
/**
* One post in the chain. When there is only one, the wrapper is invisible;
* when there are several, each is separated and the one that was actually
@@ -83,13 +117,109 @@ function renderWhen(postedAt: string | undefined): Raw {
*/
function renderSegment(segment: Segment, post: Post, threaded: boolean): Raw {
const above = post.textPosition === 'above';
return html`<section class="segment" ${threaded && segment.isAnchor ? raw('data-anchor="true"') : ''}>
return html`<section class="segment" ${threaded && segment.isAnchor ? raw('data-anchor="true"') : ''} ${
segment.quoted ? raw('data-quoted="true"') : ''
}>
${threaded && segment.isAnchor
? html`<p class="segment__mark">the post you followed</p>`
: ''}
${above ? renderText(segment, post) : ''}
${renderMedia(segment.media)}
${above ? '' : renderText(segment, post)}
${segment.quoted ? renderQuoted(segment.quoted, post) : ''}
</section>`;
}
/** Short enough to sit on a comment's byline without wrapping. Absolute
* rather than "3 hours ago", because the page is cached for an hour and a
* relative time would quietly become wrong while it sat there. */
function shortWhen(postedAt: string | undefined): Raw {
if (!postedAt) return html``;
const date = new Date(postedAt);
if (Number.isNaN(date.getTime())) return html``;
return html`<time datetime="${date.toISOString()}">${date.toLocaleString('en-CA', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}</time>`;
}
/** Everything hanging off a comment, however deep. Shown only while it is
* collapsed, so what a fold is hiding is never a mystery. */
function descendantsOf(comment: Comment): number {
return comment.replies.reduce((total, reply) => total + 1 + descendantsOf(reply), 0);
}
/**
* One comment and everything under it.
*
* A `<details>` per comment, so collapsing works with the stylesheet turned
* off and the keyboard alone, and takes the whole subtree with it because
* the replies are nested inside rather than listed alongside.
*/
function renderComment(comment: Comment, depth: number): Raw {
const marks = [
comment.isAuthor ? 'OP' : undefined,
comment.distinguished === 'moderator' ? 'MOD' : undefined,
comment.distinguished === 'admin' ? 'ADMIN' : undefined,
].filter((mark) => mark !== undefined);
return html`<details class="c" open data-depth="${depth}">
<summary class="c__head">
<span class="c__author">${comment.author}</span>
${marks.map((mark) => html`<span class="c__mark c__mark--${mark.toLowerCase()}">${mark}</span>`)}
${typeof comment.score === 'number'
? html`<span class="c__score">${comment.score} ${Math.abs(comment.score) === 1 ? 'point' : 'points'}</span>`
: ''}
${shortWhen(comment.postedAt)}
${descendantsOf(comment) > 0
? html`<span class="c__hidden">+${descendantsOf(comment)} ${
descendantsOf(comment) === 1 ? 'reply' : 'replies'
}</span>`
: ''}
</summary>
${comment.text ? html`<div class="c__body">${renderMarkdown(comment.text)}</div>` : ''}
${comment.replies.length || comment.moreReplies
? html`<div class="c__replies">
${comment.replies.map((reply) => renderComment(reply, depth + 1))}
${comment.moreReplies
? html`<p class="c__more">${comment.moreReplies} more ${
comment.moreReplies === 1 ? 'reply' : 'replies'
}, on Reddit</p>`
: ''}
</div>`
: ''}
</details>`;
}
/**
* The conversation under the post, where the platform has one.
*
* Everything the first page carried, in the order it was ranked. What is
* missing is what was behind a "load more" — following those means going to
* the platform, so they are counted rather than pretended away.
*/
function renderComments(post: Post): Raw {
const comments = post.comments;
if (!comments?.length) return html``;
const threads = comments.length;
const shown = comments.reduce((total, comment) => total + 1 + descendantsOf(comment), 0);
const total = post.commentCount;
return html`<section class="thread" data-platform="${post.platform}">
<header class="thread__head">
<h2 class="thread__title">Comments</h2>
<span class="thread__count">${
total !== undefined && total > shown ? html`${shown} of ${total}` : html`${shown}`
}, in ${threads} ${threads === 1 ? 'thread' : 'threads'}</span>
<button type="button" class="thread__toggle" data-collapsed="false" hidden>Collapse all</button>
</header>
<div class="thread__list">${comments.map((comment) => renderComment(comment, 0))}</div>
${post.moreComments
? html`<p class="thread__more">${post.moreComments} more, behind “load more” on Reddit.</p>`
: ''}
</section>`;
}
@@ -118,7 +248,8 @@ export function renderPost(post: Post): string {
${renderWhen(anchor?.postedAt)}
${originalUrlBlock(post.originalUrl, post.platformLabel)}
</footer>
</article>`;
</article>
${renderComments(post)}`;
const who = post.author.displayName ?? post.author.handle;
return layout(`${who} on ${post.platformLabel}`, body);
+1
View File
@@ -8,6 +8,7 @@ const PROFILE_BASE: Record<PlatformId, string> = {
instagram: 'https://www.instagram.com/',
tiktok: 'https://www.tiktok.com/@',
bluesky: 'https://bsky.app/profile/',
reddit: 'https://www.reddit.com/user/',
};
const TAG_BASE: Partial<Record<PlatformId, string>> = {
+61 -2
View File
@@ -1,5 +1,5 @@
/** The five platforms v1 understands. */
export type PlatformId = 'x' | 'threads' | 'instagram' | 'tiktok' | 'bluesky';
/** The platforms antisocial understands. */
export type PlatformId = 'x' | 'threads' | 'instagram' | 'tiktok' | 'bluesky' | 'reddit';
/**
* Something fetchable that lives on someone else's CDN.
@@ -40,6 +40,24 @@ export type Author = {
avatar?: Asset;
};
/**
* A post that the post being shown is talking about.
*
* Held apart from the segment quoting it rather than folded into it. The
* words and the pictures belong to someone else, and showing them under the
* quoter's name — which is what folding them in amounts to — tells the
* reader something untrue about who said what.
*/
export type Quoted = {
author: Author;
text?: string;
media: Media[];
/** ISO 8601. */
postedAt?: string;
/** The quoted post's own URL, so it can be opened on its own. */
url?: string;
};
/**
* One post. Usually a whole `Post` is a single segment, but on the platforms
* where people write in chains — Bluesky and Threads — the author's own
@@ -47,15 +65,44 @@ export type Author = {
* do not.
*/
export type Segment = {
/** Only Reddit gives a post a headline of its own. Everywhere else the
* first words of the body do that job, so this is left unset. */
title?: string;
text?: string;
media: Media[];
/** ISO 8601. */
postedAt?: string;
/** The post this one quotes, where there is one. */
quoted?: Quoted;
/** The post the link actually pointed at. Only meaningful when a thread
* has more than one segment. */
isAnchor?: boolean;
};
/**
* One comment, with the replies that hang off it.
*
* A tree rather than a flat list with depths: collapsing a comment has to
* take everything under it along, and nesting is what makes that free.
*/
export type Comment = {
/** Already prefixed, e.g. `u/someone`. `[deleted]` is left as it came. */
author: string;
text?: string;
/** ISO 8601. */
postedAt?: string;
/** Absent when the platform is still hiding it on a new comment. */
score?: number;
/** The author of the post, replying under it. */
isAuthor?: boolean;
/** Marked by the platform as a moderator or admin comment. */
distinguished?: string;
replies: Comment[];
/** Replies that exist upstream but were not on the page we were given.
* Shown as a count, since following them means going to the platform. */
moreReplies?: number;
};
/**
* The single shape every adapter produces and the renderer consumes. Adding
* a platform means producing one of these; nothing downstream changes.
@@ -73,6 +120,18 @@ export type Post = {
textPosition: 'above' | 'below';
/** In the order they were written. Never empty. */
segments: Segment[];
/**
* The conversation under the post, where the platform has one worth
* showing. Only Reddit fills this in: elsewhere the replies are strangers
* arguing beneath something that was shared for its own sake, but on
* Reddit the thread is usually the point of the link.
*/
comments?: Comment[];
/** Top-level comments the first page did not carry. */
moreComments?: number;
/** What the platform says the total is, which is larger than what we
* show whenever `moreComments` is set. */
commentCount?: number;
};
/** Most platforms have no notion of a chain, so their adapters use this. */