From 7db60e883965ffa1f01ac3dd308be286bf5e17dc Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 9 Aug 2026 09:42:15 +0100 Subject: [PATCH] Support Reddit gallery posts --- src/reddit/api.ts | 37 ++++++++++++++++++ tests/reddit-api.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/src/reddit/api.ts b/src/reddit/api.ts index 43d11a0..ea0d9b4 100644 --- a/src/reddit/api.ts +++ b/src/reddit/api.ts @@ -63,6 +63,20 @@ export interface RedditPost { resolutions: Array<{ url: string; width: number; height: number }>; }>; }; + is_gallery?: boolean; + gallery_data?: { + items: Array<{ media_id: string; id?: number }>; + }; + media_metadata?: Record; created_utc: number; over_18: boolean; spoiler: boolean; @@ -252,6 +266,8 @@ export class RedditApiClient { } private hasImage(post: RedditPost): boolean { + if (this.getGalleryImageUrl(post)) return true; + // Check post_hint if (post.post_hint === 'image') return true; @@ -272,6 +288,9 @@ export class RedditApiClient { } getImageUrl(post: RedditPost): string | null { + const galleryImageUrl = this.getGalleryImageUrl(post); + if (galleryImageUrl) return galleryImageUrl; + // Prefer high-quality preview images (already processed by Reddit) if (post.preview?.images?.[0]?.source?.url) { // Reddit encodes URLs in preview, need to decode HTML entities @@ -312,6 +331,24 @@ export class RedditApiClient { return null; } + /** Return the first valid gallery image in Reddit's declared display order. */ + private getGalleryImageUrl(post: RedditPost): string | null { + if (!post.is_gallery || !post.media_metadata) return null; + + const orderedMediaIds = post.gallery_data?.items.map((item) => item.media_id) + ?? Object.keys(post.media_metadata); + for (const mediaId of orderedMediaIds) { + const media = post.media_metadata[mediaId]; + if (!media || (media.status && media.status !== 'valid')) continue; + if (media.m && !media.m.startsWith('image/')) continue; + + const sourceUrl = media.s?.u ?? media.s?.gif; + if (sourceUrl) return this.decodeRedditUrl(sourceUrl); + } + + return null; + } + private decodeRedditUrl(url: string): string { return url.replace(/&/g, '&'); } diff --git a/tests/reddit-api.test.ts b/tests/reddit-api.test.ts index aecce95..c547060 100644 --- a/tests/reddit-api.test.ts +++ b/tests/reddit-api.test.ts @@ -69,6 +69,51 @@ describe('RedditApiClient', () => { expect(url).toBe('http://i.redd.it/image.jpg'); }); + it('extracts the first valid image from a Reddit gallery in display order', () => { + const post = validPostFactory({ + url: 'https://www.reddit.com/gallery/t3_gallery', + is_gallery: true, + gallery_data: { + items: [ + { media_id: 'second-in-metadata' }, + { media_id: 'first-in-metadata' }, + ], + }, + media_metadata: { + 'first-in-metadata': { + status: 'valid', + m: 'image/jpg', + s: { u: 'https://i.redd.it/first.jpg' }, + }, + 'second-in-metadata': { + status: 'valid', + m: 'image/jpg', + s: { u: 'https://preview.redd.it/second.jpg?width=1080&format=pjpg' }, + }, + }, + }); + + expect(client.getImageUrl(post)).toBe( + 'https://preview.redd.it/second.jpg?width=1080&format=pjpg', + ); + }); + + it('ignores invalid and non-image gallery media', () => { + const post = validPostFactory({ + url: 'https://www.reddit.com/gallery/t3_gallery', + is_gallery: true, + gallery_data: { + items: [{ media_id: 'invalid' }, { media_id: 'video' }], + }, + media_metadata: { + invalid: { status: 'failed', m: 'image/jpg', s: { u: 'https://i.redd.it/invalid.jpg' } }, + video: { status: 'valid', m: 'video/mp4', s: { u: 'https://v.redd.it/video.mp4' } }, + }, + }); + + expect(client.getImageUrl(post)).toBeNull(); + }); + it('accepts posts with i.redd.it URLs', () => { const post = validPostFactory({ url: 'https://i.redd.it/abc123.jpg' }); const url = client.getImageUrl(post); @@ -615,6 +660,42 @@ describe('RedditApiClient', () => { expect(result[0].title).toBe('Image post 1'); }); + it('keeps Reddit gallery posts whose images are stored in media metadata', async () => { + const galleryPost: RedditPost = { + id: 't3_gallery', + name: 't3_gallery', + subreddit: 'test', + author: 'gallery-user', + title: 'Gallery post', + url: 'https://www.reddit.com/gallery/t3_gallery', + permalink: '/r/test/comments/t3_gallery/gallery_post', + thumbnail: 'nsfw', + is_self: false, + is_gallery: true, + gallery_data: { items: [{ media_id: 'media-1' }] }, + media_metadata: { + 'media-1': { + status: 'valid', + m: 'image/jpg', + s: { u: 'https://preview.redd.it/gallery-image.jpg' }, + }, + }, + created_utc: 1000000, + over_18: true, + spoiler: false, + }; + const mockAxios = { + get: vi.fn().mockResolvedValue({ + data: { data: { children: [{ data: galleryPost }] } }, + }), + }; + vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance); + const client2 = new RedditApiClient(getTestConfig(), logger); + + await expect(client2.getNewPosts('test')).resolves.toEqual([galleryPost]); + expect(client2.getImageUrl(galleryPost)).toBe('https://preview.redd.it/gallery-image.jpg'); + }); + it('filters out deleted authors', async () => { const mockResponse = { data: { data: { children: [