Files
antisocial/test/reddit.test.ts
T
thatguygriffandClaude Opus 5 0db18547c8
CI / Typecheck, test, build (pull_request) Successful in 11s
Show the pictures inside Reddit comments
A comment that was a picture rendered as either a link or, for a Giphy, the
literal text `![gif](giphy|Ve7wX45gaOFmw8eeEM)`. On r/aww that is most of the
thread.

Reddit writes an inline image as a token rather than an address, in three
shapes: `![gif](giphy|ID)` for a Giphy, `![img](emote|t5_2th52|4358)` for a
subreddit emote, and `![img](jo8gf0ca92zd1)` for an image uploaded straight to
the comment. The useful part is that all three tokens are keys in that same
comment's own `media_metadata`, so this is one lookup and not three special
cases. Nothing in the adapter has to know what Giphy is.

The fourth shape is someone pasting the address of a picture, which on Reddit
is how most images in comments actually arrive -- 117 of them against 21
Giphys in the sample I scanned. Those are shown as pictures too, decided by
the file extension. A link that is not to an image stays a link.

Animated ones take `s.gif` over `s.mp4` even though the MP4 is several times
smaller: a GIF moves on its own in an `<img>`, and an MP4 would need a player
element with autoplay, loop and muted set, for something the size of a
postage stamp.

Everything goes through the `/m/` proxy, like all other media. Without that a
comment thread would have the reader's browser fetch dozens of files straight
from Reddit, which is the one thing this whole app exists to avoid.

Placing the image is the renderer's job, not the Markdown parser's, because
the proxy is a render-time concern and markdown.ts knows nothing about it --
so it takes an optional `ImageRenderer` and, without one, an image stays a
link exactly as before. The parser also learned `![...]` proper: the link rule
was matching from the `[` and stranding the `!` as text.

Verified on r/aww/comments/171dxph, which carries one Giphy and 77 pasted
images: 35 render on the first page, all 35 load, all 35 through the proxy,
no upstream address reaches the page, no token is left unresolved, and
nothing overflows the column or scrolls the page sideways.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
2026-08-27 16:14:06 -03:00

250 lines
9.3 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { commentsFrom, mediaFromLink, resolveInlineImages, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
import { reddit } from '../src/platforms/reddit.ts';
import { originalUrlFor } from '../src/platforms/index.ts';
import { fixture } from './helpers.ts';
const URL_ = 'https://www.reddit.com/r/aww/comments/abc123/a_post/';
test('a text post is its title, and carries no media', () => {
const post = toPost(fixture('reddit/self.json'), URL_);
assert.equal(post.platform, 'reddit');
assert.equal(post.author.handle, 'r/AskReddit');
assert.match(post.author.displayName ?? '', /^u\//);
assert.equal(post.segments[0]?.title, "What's a healthy food that pleases the taste buds too?");
assert.deepEqual(post.segments[0]?.media, []);
assert.ok(post.segments[0]?.postedAt);
});
test('a gallery keeps every picture, in the order the post arranged them', () => {
const link = fixture<[{ data: { children: Array<{ data: Record<string, unknown> }> } }]>(
'reddit/gallery.json',
)[0].data.children[0]?.data as Parameters<typeof mediaFromLink>[0];
const media = mediaFromLink(link);
const ids = (link.gallery_data?.items ?? []).map((item) => item.media_id);
assert.equal(media.length, ids.length);
assert.ok(media.every((m) => m.kind === 'image'));
// The pictures live in `media_metadata`, keyed and unordered; the order is
// only in `gallery_data`, so joining the two is the whole job.
media.forEach((item, index) => assert.ok(item.url.includes(String(ids[index]))));
assert.ok(media.every((m) => m.width && m.height));
});
test('a silent video is served as the plain MP4, with a real image for a poster', () => {
const media = toPost(fixture('reddit/video.json'), URL_).segments[0]?.media ?? [];
assert.equal(media.length, 1);
const video = media[0];
assert.equal(video?.kind, 'video');
assert.match(video?.url ?? '', /\.mp4/);
assert.ok(video?.kind === 'video' && video.poster);
// Not the scrubber file, which is itself an MP4 and would render nothing.
assert.ok(video?.kind === 'video' && !video.poster?.url.endsWith('.mp4'));
assert.equal(video?.kind === 'video' ? video.hls : undefined, undefined);
});
test('a video with sound is the HLS playlist, because the MP4 has no audio track', () => {
const media = mediaFromLink({
secure_media: {
reddit_video: {
fallback_url: 'https://v.redd.it/abc/CMAF_720.mp4?source=fallback',
hls_url: 'https://v.redd.it/abc/HLSPlaylist.m3u8',
has_audio: true,
width: 1920,
height: 1080,
duration: 42,
},
},
});
assert.deepEqual(media, [
{
kind: 'video',
url: 'https://v.redd.it/abc/HLSPlaylist.m3u8',
hls: true,
direct: true,
width: 1920,
height: 1080,
durationSec: 42,
},
]);
});
test('a link post keeps the destination, since it is the whole content of the post', () => {
const post = toPost(fixture('reddit/link.json'), URL_);
assert.match(post.segments[0]?.text ?? '', /^https:\/\/www\.nytimes\.com\//);
});
test('a crosspost shows what it is crossposting', () => {
const media = mediaFromLink({
is_self: false,
crosspost_parent_list: [
{ url_overridden_by_dest: 'https://i.redd.it/inner.jpg', post_hint: 'image' },
],
});
assert.deepEqual(media, [{ kind: 'image', url: 'https://i.redd.it/inner.jpg' }]);
});
test('comments come back as a tree, with the counts of what is missing', () => {
const post = toPost(fixture('reddit/gallery.json'), URL_);
assert.equal(post.comments?.length, 3);
assert.ok((post.moreComments ?? 0) > 0, 'the "more comments" node should be counted, not dropped');
assert.equal(post.commentCount, 443);
const first = post.comments?.[0];
assert.equal(first?.author, 'u/Background_Round_853');
assert.equal(first?.isAuthor, true, 'the poster replying under their own post');
assert.equal(first?.score, 6583);
assert.ok((first?.replies.length ?? 0) > 0);
assert.ok(first?.replies.every((reply) => Array.isArray(reply.replies)));
});
test('a score the platform is still hiding is left off rather than guessed at', () => {
const { comments } = commentsFrom({
data: {
children: [
{ kind: 't1', data: { author: 'a', body: 'new', score: 1, score_hidden: true, replies: '' } },
{ kind: 't1', data: { author: 'b', body: 'older', score: 42, score_hidden: false, replies: '' } },
],
},
});
assert.equal(comments[0]?.score, undefined);
assert.equal(comments[1]?.score, 42);
});
test('a deleted commenter keeps the platform\'s own word for it', () => {
const { comments } = commentsFrom({
data: { children: [{ kind: 't1', data: { author: '[deleted]', body: '[removed]', replies: '' } }] },
});
assert.equal(comments[0]?.author, '[deleted]');
});
test('an empty reply listing is the string "", not an object', () => {
// Reddit says "no replies" with an empty string, which is the shape most
// likely to be read as a listing and crash the walk.
const { comments } = commentsFrom({
data: { children: [{ kind: 't1', data: { author: 'a', body: 'x', replies: '' } }] },
});
assert.deepEqual(comments[0]?.replies, []);
});
test('a redd.it share code is rebuilt, since the rewrite drops the host', () => {
assert.equal(originalUrlFor(reddit, 'abc123', ''), 'https://redd.it/abc123');
assert.equal(
originalUrlFor(reddit, 'r/aww/comments/abc123/a_post/', '?share_id=xyz&utm_source=share'),
'https://www.reddit.com/r/aww/comments/abc123/a_post/',
);
});
test('reddit links route to the adapter, and its media hosts do not', () => {
assert.ok(reddit.matchesHost('www.reddit.com'));
assert.ok(reddit.matchesHost('old.reddit.com'));
assert.ok(reddit.matchesHost('redd.it'));
assert.ok(!reddit.matchesHost('bsky.app'));
});
test('the page fallback rebuilds nesting from the depth on each comment', () => {
// Reddit renders the tree flat, so depth is the only thing saying what
// replies to what.
const tree = treeFromDepths([
{ depth: 0, author: 'a', score: 5, created: '', text: 'first' },
{ depth: 1, author: 'b', score: 4, created: '', text: 'under first' },
{ depth: 2, author: 'c', score: 3, created: '', text: 'under b' },
// Back up two levels: this belongs to `first`, not to `c`.
{ depth: 1, author: 'd', score: 2, created: '', text: 'also under first' },
{ depth: 0, author: 'e', score: 1, created: '', text: 'second' },
]);
assert.equal(tree.length, 2);
assert.equal(tree[0]?.replies.length, 2);
assert.equal(tree[0]?.replies[0]?.replies[0]?.text, 'under b');
assert.equal(tree[0]?.replies[1]?.text, 'also under first');
assert.deepEqual(tree[1]?.replies, []);
});
test('a comment the page gave no text for is dropped rather than shown empty', () => {
assert.deepEqual(treeFromDepths([{ depth: 0, author: 'a', score: 1, created: '', text: '' }]), []);
});
// Real shapes, captured from comments carrying each kind.
const GIPHY = {
'giphy|Ve7wX45gaOFmw8eeEM': {
status: 'valid',
e: 'AnimatedImage',
m: 'image/gif',
s: {
y: 200,
x: 304,
gif: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9',
mp4: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&format=mp4&s=a389',
},
},
};
const UPLOAD = {
jo8gf0ca92zd1: {
status: 'valid',
e: 'Image',
m: 'image/jpeg',
s: { y: 1270, x: 1274, u: 'https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226' },
},
};
test('a giphy comment points at the gif rather than at a token', () => {
// `![gif](giphy|ID)` is not an address, and renders as nothing at all until
// it is looked up in the comment's own media_metadata.
assert.equal(
resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY),
'![gif](https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9)',
);
});
test('the animated form takes the gif, which plays on its own', () => {
const out = resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY);
assert.ok(out.includes('.gif?'), out);
assert.ok(!out.includes('format=mp4'), 'an mp4 would need a player to move');
});
test('an uploaded image resolves through the same lookup', () => {
// Giphy, emotes and uploads are all a token that is a key in the same map,
// so none of them needs naming.
assert.equal(
resolveInlineImages('![img](jo8gf0ca92zd1)', UPLOAD),
'![img](https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226)',
);
});
test('a target that is already an address is left alone', () => {
const already = '![a](https://i.redd.it/x.jpg)';
assert.equal(resolveInlineImages(already, UPLOAD), already);
assert.equal(resolveInlineImages(already, undefined), already);
});
test('a token with no entry, or a broken one, is not invented', () => {
assert.equal(resolveInlineImages('![gif](giphy|missing)', GIPHY), '![gif](giphy|missing)');
assert.equal(
resolveInlineImages('![img](gone)', { gone: { status: 'failed', e: 'Image' } }),
'![img](gone)',
);
});
test('inline images survive the walk into the comment tree', () => {
const { comments } = commentsFrom({
data: {
children: [{
kind: 't1',
data: {
author: 'a',
body: 'ha ![gif](giphy|Ve7wX45gaOFmw8eeEM)',
media_metadata: GIPHY,
replies: '',
},
}],
},
});
assert.match(comments[0]?.text ?? '', /external-preview\.redd\.it/);
});