`` is a token, not an address, and the only thing that turned it into one was a lookup in the comment's own `media_metadata`. Reddit ships plenty of comments carrying such a token and no `media_metadata` at all, and with nothing to look it up in the token itself was what the comment showed. Giphy is the one of the three token kinds whose id means something off Reddit, so that one can be resolved without the lookup. A variant name after the id is dropped: Giphy does not serve every variant of every gif, but the full one is always there. The other two still resolve only through the metadata. An emote id and an upload id name nothing outside Reddit, so with no entry for them there is still nothing to point them at. Closes #10 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PLkmgp1fWbA4XbarxKdRKt
300 lines
11 KiB
TypeScript
300 lines
11 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', () => {
|
|
// `` is not an address, and renders as nothing at all until
|
|
// it is looked up in the comment's own media_metadata.
|
|
assert.equal(
|
|
resolveInlineImages('', GIPHY),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test('the animated form takes the gif, which plays on its own', () => {
|
|
const out = resolveInlineImages('', GIPHY);
|
|
assert.ok(out.includes('.gif?'), out);
|
|
assert.ok(!out.includes('format=mp4'), 'an mp4 would need a player to move');
|
|
});
|
|
|
|
test('an uploaded image resolves through the same lookup', () => {
|
|
// Giphy, emotes and uploads are all a token that is a key in the same map,
|
|
// so none of them needs naming.
|
|
assert.equal(
|
|
resolveInlineImages('', UPLOAD),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test('a target that is already an address is left alone', () => {
|
|
const already = '';
|
|
assert.equal(resolveInlineImages(already, UPLOAD), already);
|
|
assert.equal(resolveInlineImages(already, undefined), already);
|
|
});
|
|
|
|
test('a token with no entry, or a broken one, is not invented', () => {
|
|
// An upload id and an emote id mean nothing off Reddit, so with no entry to
|
|
// look them up in there is nothing to point them at.
|
|
assert.equal(resolveInlineImages('', GIPHY), '');
|
|
assert.equal(
|
|
resolveInlineImages('', { gone: { status: 'failed', e: 'Image' } }),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test('a giphy token resolves even when the comment carried no metadata', () => {
|
|
// Reddit ships plenty of these with no `media_metadata` at all. The id in
|
|
// the token is Giphy's own, so it does not need Reddit to be readable.
|
|
assert.equal(
|
|
resolveInlineImages('', undefined),
|
|
'',
|
|
);
|
|
assert.equal(
|
|
resolveInlineImages('', GIPHY),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test('the metadata still wins where there is any, being what Reddit will serve', () => {
|
|
assert.match(resolveInlineImages('', GIPHY), /redd\.it/);
|
|
});
|
|
|
|
test('a variant name after the id is dropped, not all of them being served', () => {
|
|
assert.equal(
|
|
resolveInlineImages('', undefined),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test('only a giphy token is guessed at, and only a well-formed one', () => {
|
|
for (const token of ['emote|t5_2th52|4358', 'giphy|', 'giphy|../evil', 'giphy|a|b|c']) {
|
|
assert.equal(resolveInlineImages(``, undefined), ``);
|
|
}
|
|
});
|
|
|
|
test('a real comment carrying a giphy token gets the gif', () => {
|
|
// Captured from the post itself: the comment has the token and no
|
|
// `media_metadata`, which is the shape that used to show the token instead.
|
|
const post = toPost(fixture('reddit/video.json'), URL_);
|
|
const all: string[] = [];
|
|
const walk = (list: typeof post.comments) => {
|
|
for (const comment of list ?? []) {
|
|
if (comment.text) all.push(comment.text);
|
|
walk(comment.replies);
|
|
}
|
|
};
|
|
walk(post.comments);
|
|
|
|
const gif = all.find((text) => text.includes('giphy'));
|
|
assert.ok(gif, 'the fixture should still carry a giphy comment');
|
|
assert.match(gif, /!\[gif\]\(https:\/\/i\.giphy\.com\/media\/QfzMP70zmNQiDf5sGP\/giphy\.gif\)/);
|
|
});
|
|
|
|
test('inline images survive the walk into the comment tree', () => {
|
|
const { comments } = commentsFrom({
|
|
data: {
|
|
children: [{
|
|
kind: 't1',
|
|
data: {
|
|
author: 'a',
|
|
body: 'ha ',
|
|
media_metadata: GIPHY,
|
|
replies: '',
|
|
},
|
|
}],
|
|
},
|
|
});
|
|
assert.match(comments[0]?.text ?? '', /external-preview\.redd\.it/);
|
|
});
|