Support Reddit gallery posts
This commit is contained in:
1 parent
5c9839926d
commit
7db60e8839
2 files changed
+118
No files matched your search
@@ -63,6 +63,20 @@ export interface RedditPost {
|
|||||||
resolutions: Array<{ url: string; width: number; height: number }>;
|
resolutions: Array<{ url: string; width: number; height: number }>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
is_gallery?: boolean;
|
||||||
|
gallery_data?: {
|
||||||
|
items: Array<{ media_id: string; id?: number }>;
|
||||||
|
};
|
||||||
|
media_metadata?: Record<string, {
|
||||||
|
status?: string;
|
||||||
|
m?: string;
|
||||||
|
s?: {
|
||||||
|
u?: string;
|
||||||
|
gif?: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
created_utc: number;
|
created_utc: number;
|
||||||
over_18: boolean;
|
over_18: boolean;
|
||||||
spoiler: boolean;
|
spoiler: boolean;
|
||||||
@@ -252,6 +266,8 @@ export class RedditApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hasImage(post: RedditPost): boolean {
|
private hasImage(post: RedditPost): boolean {
|
||||||
|
if (this.getGalleryImageUrl(post)) return true;
|
||||||
|
|
||||||
// Check post_hint
|
// Check post_hint
|
||||||
if (post.post_hint === 'image') return true;
|
if (post.post_hint === 'image') return true;
|
||||||
|
|
||||||
@@ -272,6 +288,9 @@ export class RedditApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getImageUrl(post: RedditPost): string | null {
|
getImageUrl(post: RedditPost): string | null {
|
||||||
|
const galleryImageUrl = this.getGalleryImageUrl(post);
|
||||||
|
if (galleryImageUrl) return galleryImageUrl;
|
||||||
|
|
||||||
// Prefer high-quality preview images (already processed by Reddit)
|
// Prefer high-quality preview images (already processed by Reddit)
|
||||||
if (post.preview?.images?.[0]?.source?.url) {
|
if (post.preview?.images?.[0]?.source?.url) {
|
||||||
// Reddit encodes URLs in preview, need to decode HTML entities
|
// Reddit encodes URLs in preview, need to decode HTML entities
|
||||||
@@ -312,6 +331,24 @@ export class RedditApiClient {
|
|||||||
return null;
|
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 {
|
private decodeRedditUrl(url: string): string {
|
||||||
return url.replace(/&/g, '&');
|
return url.replace(/&/g, '&');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,51 @@ describe('RedditApiClient', () => {
|
|||||||
expect(url).toBe('http://i.redd.it/image.jpg');
|
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', () => {
|
it('accepts posts with i.redd.it URLs', () => {
|
||||||
const post = validPostFactory({ url: 'https://i.redd.it/abc123.jpg' });
|
const post = validPostFactory({ url: 'https://i.redd.it/abc123.jpg' });
|
||||||
const url = client.getImageUrl(post);
|
const url = client.getImageUrl(post);
|
||||||
@@ -615,6 +660,42 @@ describe('RedditApiClient', () => {
|
|||||||
expect(result[0].title).toBe('Image post 1');
|
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 () => {
|
it('filters out deleted authors', async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
data: { data: { children: [
|
data: { data: { children: [
|
||||||
|
|||||||
Reference in new issue
Block a user