feat(swarm,posts): Add NSFW filtering and link preview enrichment

- Add NSFW content filtering based on user's nsfwEnabled session setting
- Pass includeNsfw preference to fetchSwarmTimeline in curated and swarm feeds
- Remove in-memory caching from swarm API route to respect per-user preferences
- Add link preview enrichment for swarm posts with URLs but no preview data
- Implement extractFirstUrl utility to parse URLs from post content
- Implement fetchLinkPreview to fetch metadata from external URLs with 3s timeout
- Implement enrichPostsWithPreviews to enrich posts asynchronously
- Skip video URLs (YouTube, Vimeo) from preview enrichment as they use VideoEmbed
- Update swarm API documentation to reflect NSFW filtering behavior
- Improves content discovery by enriching posts with link metadata while respecting user content preferences
This commit is contained in:
AskIt
2026-01-26 03:27:14 +01:00
parent dc9ed98091
commit b7e8727986
3 changed files with 99 additions and 25 deletions
+83 -1
View File
@@ -17,6 +17,85 @@ interface TimelineOptions {
includeNsfw?: boolean; // Whether to include NSFW content
}
/**
* Extract the first URL from post content
*/
function extractFirstUrl(content: string): string | null {
const urlMatch = content.match(/https?:\/\/[^\s<>"{}|\\^`[\]]+/);
if (!urlMatch) return null;
// Clean trailing punctuation
return urlMatch[0].replace(/[)\].,!?;:]+$/, '');
}
/**
* Fetch link preview for a URL
*/
async function fetchLinkPreview(url: string): Promise<{
url: string;
title: string | null;
description: string | null;
image: string | null;
} | null> {
try {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
const previewUrl = `${protocol}://${nodeDomain}/api/media/preview?url=${encodeURIComponent(url)}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3s timeout for previews
const response = await fetch(previewUrl, {
headers: { 'Accept': 'application/json' },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) return null;
const data = await response.json();
return {
url: data.url || url,
title: data.title || null,
description: data.description || null,
image: data.image || null,
};
} catch {
return null;
}
}
/**
* Enrich swarm posts with link previews if they have URLs but no preview data
*/
async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]> {
const enrichmentPromises = posts.map(async (post) => {
// Skip if already has link preview data
if (post.linkPreviewUrl) return post;
// Extract URL from content
const url = extractFirstUrl(post.content);
if (!url) return post;
// Skip video URLs (handled by VideoEmbed component)
if (url.match(/(youtube\.com|youtu\.be|vimeo\.com)/)) return post;
// Fetch preview
const preview = await fetchLinkPreview(url);
if (!preview) return post;
return {
...post,
linkPreviewUrl: preview.url,
linkPreviewTitle: preview.title || undefined,
linkPreviewDescription: preview.description || undefined,
linkPreviewImage: preview.image || undefined,
};
});
return Promise.all(enrichmentPromises);
}
/**
* Fetch timeline from a single node
*/
@@ -121,8 +200,11 @@ export async function fetchSwarmTimeline(
return true;
});
// Enrich posts that have URLs but no link preview data
const enrichedPosts = await enrichPostsWithPreviews(uniquePosts);
return {
posts: uniquePosts,
posts: enrichedPosts,
sources,
fetchedAt: new Date().toISOString(),
};