diff --git a/backend/src/ingestion/adapters/base.ts b/backend/src/ingestion/adapters/base.ts index 723dfd7..e767543 100644 --- a/backend/src/ingestion/adapters/base.ts +++ b/backend/src/ingestion/adapters/base.ts @@ -1,4 +1,4 @@ -import type { Source, ContentItem } from '../../storage/db/types.js'; +import type { Source, ContentItem, TweetMediaItem } from '../../storage/db/types.js'; import { cleanHtml, toSummary } from '../clean.js'; export interface FetchedItem { @@ -10,7 +10,7 @@ export interface FetchedItem { link: string; publishedAt: string; /** Set by the Nitter adapter only — carries the tweet's author info through to ContentItem.tweet. */ - tweet?: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null }; + tweet?: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null; media: TweetMediaItem[] }; raw: unknown; } diff --git a/backend/src/ingestion/adapters/nitter.ts b/backend/src/ingestion/adapters/nitter.ts index fec2118..f249b6b 100644 --- a/backend/src/ingestion/adapters/nitter.ts +++ b/backend/src/ingestion/adapters/nitter.ts @@ -7,29 +7,54 @@ // two outlets' coverage of the same news event does — same reasoning as YouTube. import Parser from 'rss-parser'; -import type { Source } from '../../storage/db/types.js'; +import type { Source, TweetMediaItem } from '../../storage/db/types.js'; import type { SourceAdapter, FetchedItem } from './base.js'; import { logger } from '../../storage/db/logs.js'; import { getSettings } from '../../storage/db/settings.js'; +/** fxtwitter caps a tweet at 4 attached photos/videos/gifs, in tweet display order. */ +const MAX_TWEET_MEDIA = 4; + const parser = new Parser>(); const USER_AGENT = 'Mozilla/5.0 (compatible; HomefeedBot/1.0; self-hosted RSS reader)'; const FETCH_TIMEOUT_MS = 10_000; /** - * Shape confirmed against a real `curl https://api.fxtwitter.com//status/` - * response: `text`, `created_timestamp` (unix seconds), `author.name`/`avatar_url` all - * verified exactly as read below. `media.photos[].url` is still unconfirmed — that - * response had no attached photo — but is read with optional chaining regardless, so a - * shape mismatch there just falls back to the RSS description's own (see - * fetch()'s photoUrl fallback) rather than breaking ingestion. + * Shape confirmed against two real `curl https://api.fxtwitter.com//status/` + * responses: `text`, `created_timestamp` (unix seconds), `author.name`/`avatar_url`, and + * (from a second, video-attached tweet) `media.all[]` — an ordered array covering both + * photos and videos, each with `type` ('photo' | 'video' | 'gif'), `url` (the direct + * playable/displayable URL — for video this is a real .mp4, not the .m3u8 playlist also + * present under `formats`), `thumbnail_url` (video/gif poster frame), and `width`/`height`. + * `media.all` is preferred over `media.photos`/`media.videos` since it's the only field + * that preserves the tweet's original media order. */ +interface FxTweetMedia { + type?: string; + url?: string; + thumbnail_url?: string; + width?: number; + height?: number; +} + interface FxTweet { text?: string; created_timestamp?: number; author?: { name?: string; screen_name?: string; avatar_url?: string }; - media?: { photos?: { url?: string }[] }; + media?: { all?: FxTweetMedia[]; photos?: FxTweetMedia[] }; +} + +/** Maps fxtwitter's media shape to our own, capped at the 4 items a tweet can carry. */ +function toTweetMedia(enrichment: FxTweet | null): TweetMediaItem[] { + const items = enrichment?.media?.all ?? enrichment?.media?.photos ?? []; + return items.slice(0, MAX_TWEET_MEDIA).map((m): TweetMediaItem => ({ + type: m.type === 'video' || m.type === 'gif' ? m.type : 'photo', + url: m.url ?? '', + thumbnailUrl: m.thumbnail_url ?? null, + width: m.width ?? null, + height: m.height ?? null + })).filter((m) => m.url); } /** @@ -103,7 +128,12 @@ export const nitterAdapter: SourceAdapter = { const authorName = enrichment?.author?.name ?? handle; const avatarUrl = enrichment?.author?.avatar_url ?? null; const text = enrichment?.text ?? ownHtml; - const photoUrl = enrichment?.media?.photos?.[0]?.url ?? rssImageUrl; + // Enrichment failed (or came back with no media) — fall back to the RSS + // description's own as a single photo, same as before multi-media support. + const media = toTweetMedia(enrichment); + if (media.length === 0 && rssImageUrl) { + media.push({ type: 'photo', url: rssImageUrl, thumbnailUrl: null, width: null, height: null }); + } const publishedAt = enrichment?.created_timestamp ? new Date(enrichment.created_timestamp * 1000).toISOString() : (item.isoDate ?? item.pubDate ?? new Date().toISOString()); @@ -112,11 +142,11 @@ export const nitterAdapter: SourceAdapter = { title: item.title || text.slice(0, 100), summary: text.slice(0, 500), body: text, - images: photoUrl ? [{ url: photoUrl }] : [], + images: [], videos: [], link: item.link, publishedAt, - tweet: { id: tweetId, authorName, authorHandle: handle, avatarUrl }, + tweet: { id: tweetId, authorName, authorHandle: handle, avatarUrl, media }, raw: { rss: item, fxtwitter: enrichment } }); } diff --git a/backend/src/pipeline/publish.ts b/backend/src/pipeline/publish.ts index 90b05b3..2dd2880 100644 --- a/backend/src/pipeline/publish.ts +++ b/backend/src/pipeline/publish.ts @@ -8,7 +8,7 @@ import { logger } from '../storage/db/logs.js'; import * as articles from '../storage/db/articles.js'; import * as tags from '../storage/db/tags.js'; import * as sources from '../storage/db/sources.js'; -import type { GlobalSettings, MergedArticle, ContentItem } from '../storage/db/types.js'; +import type { GlobalSettings, MergedArticle, ContentItem, TweetMediaItem } from '../storage/db/types.js'; const FOLLOW_UP_LOOKBACK_DAYS = 3; @@ -99,6 +99,36 @@ async function resolveTweetMediaUrl( return { url, storedMediaId: null }; } +/** + * Resolves every attached photo/video/gif's url — and, for video/gif, its poster + * thumbnail — through the admin's chosen Nitter media mode. Order and item count are + * preserved; TweetCard.svelte renders this array directly, there's no separate + * "hero image" concept for tweets the way there is for regular articles. + */ +async function resolveTweetMedia( + media: TweetMediaItem[], + mode: GlobalSettings['nitterMediaMode'] +): Promise<{ media: TweetMediaItem[]; storedMediaIds: string[] }> { + const storedMediaIds: string[] = []; + const resolved: TweetMediaItem[] = []; + + for (const item of media) { + const url = await resolveTweetMediaUrl(item.url, mode); + if (url.storedMediaId) storedMediaIds.push(url.storedMediaId); + + let thumbnailUrl = item.thumbnailUrl; + if (thumbnailUrl) { + const resolvedThumb = await resolveTweetMediaUrl(thumbnailUrl, mode); + thumbnailUrl = resolvedThumb.url; + if (resolvedThumb.storedMediaId) storedMediaIds.push(resolvedThumb.storedMediaId); + } + + resolved.push({ ...item, url: url.url, thumbnailUrl }); + } + + return { media: resolved, storedMediaIds }; +} + /** * Publishes a single item as-is, with no AI calls at all — used when the AI service * isn't reachable (e.g. Ollama hasn't been set up yet, per the "assume it arrives @@ -113,15 +143,9 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings) const category = uniqueCategories([item]); const storedMediaIds: string[] = []; + // Tweets never get a "hero image" — TweetCard.svelte renders tweet.media directly. let heroImage: MergedArticle['heroImage'] = null; - if (item.tweet) { - const selected = selectBestImage([item]); - if (selected) { - const resolved = await resolveTweetMediaUrl(selected.url, settings.nitterMediaMode); - heroImage = { url: resolved.url, sourceItemId: selected.sourceItemId, selectionReason: selected.selectionReason }; - if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId); - } - } else { + if (!item.tweet) { const resolved = await resolveHeroImage([item], item.link); heroImage = resolved.heroImage; if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId); @@ -139,7 +163,15 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings) avatarUrl = resolved.url; if (resolved.storedMediaId) storedMediaIds.push(resolved.storedMediaId); } - tweet = { authorName: item.tweet.authorName, authorHandle: item.tweet.authorHandle, avatarUrl, sourceItemId: item.id }; + const resolvedMedia = await resolveTweetMedia(item.tweet.media, settings.nitterMediaMode); + storedMediaIds.push(...resolvedMedia.storedMediaIds); + tweet = { + authorName: item.tweet.authorName, + authorHandle: item.tweet.authorHandle, + avatarUrl, + sourceItemId: item.id, + media: resolvedMedia.media + }; } const article = await articles.insertArticle({ diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index ebf3829..d267eea 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -14,6 +14,16 @@ export interface Source { createdAt: string; } +/** A single photo/video/gif attached to a tweet, in the tweet's own display order — fxtwitter caps this at 4. */ +export interface TweetMediaItem { + type: 'photo' | 'video' | 'gif'; + url: string; + /** Video/gif poster frame — null for photos. */ + thumbnailUrl: string | null; + width: number | null; + height: number | null; +} + export interface ContentItem { id: string; sourceId: string; @@ -32,7 +42,7 @@ export interface ContentItem { eventId: string | null; clusterId: string | null; /** Nitter-sourced items only — null for everything else. */ - tweet: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null } | null; + tweet: { id: string; authorName: string; authorHandle: string; avatarUrl: string | null; media: TweetMediaItem[] } | null; raw: unknown; } @@ -49,8 +59,8 @@ export interface MergedArticle { body: string; heroImage: { url: string; sourceItemId: string; selectionReason: string } | null; video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null; - /** Nitter-sourced articles only — the embed card's author info (see TweetCard.svelte). Never set alongside video. */ - tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string } | null; + /** Nitter-sourced articles only — the embed card's author info and attached media (see TweetCard.svelte). Never set alongside video. */ + tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string; media: TweetMediaItem[] } | null; category: string[]; geo: string | null; eventId: string | null; diff --git a/frontend/src/lib/components/TweetCard.svelte b/frontend/src/lib/components/TweetCard.svelte index 271e217..9d98156 100644 --- a/frontend/src/lib/components/TweetCard.svelte +++ b/frontend/src/lib/components/TweetCard.svelte @@ -6,6 +6,14 @@ let { article }: { article: MergedArticle } = $props(); const sourceLabel = $derived(article.sources[0]?.sourceName ?? 'Nitter'); + const media = $derived(article.tweet?.media.slice(0, 4) ?? []); + + // Native