Files
homefeed/backend/src/ingestion/adapters/base.ts
T
Claude d4ab690061 Render actual tweet video/multi-image media instead of a single thumbnail
Tweets can carry up to 4 photos/videos/gifs; fxtwitter's media.all
preserves their original order and, for videos, gives a real playable
.mp4 plus a poster thumbnail. TweetCard now renders these as a
1/2/3/4-item grid (Twitter's own layout shapes) with fixed cell
heights so a tall portrait image no longer dictates the whole card's
height in the column view, and video/gif items play back with native
controls instead of showing a static frame. Each item's url (and a
video's thumbnail) still resolves through the configured Nitter media
mode (self-host/proxy/direct) individually.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8
2026-07-23 00:11:20 +00:00

59 lines
2.3 KiB
TypeScript

import type { Source, ContentItem, TweetMediaItem } from '../../storage/db/types.js';
import { cleanHtml, toSummary } from '../clean.js';
export interface FetchedItem {
title: string;
summary: string;
body: string | null;
images: { url: string; caption?: string; width?: number; height?: number }[];
videos: { url: string; provider?: string; embedHtml?: string }[];
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; media: TweetMediaItem[] };
raw: unknown;
}
export interface SourceAdapter {
/** Fetches and normalizes new items from this source. Should not throw on individual bad items. */
fetch(source: Source): Promise<FetchedItem[]>;
}
export function toContentItem(source: Source, item: FetchedItem): Omit<ContentItem, 'id'> {
// Feed content is frequently raw (or double-escaped) HTML — cleaned here once,
// centrally, so every adapter (RSS, API, Telegram once implemented) benefits
// without each needing its own cleanup logic.
const cleanBody = item.body ? cleanHtml(item.body) : null;
const cleanSummary = cleanHtml(item.summary) || (cleanBody ? toSummary(cleanBody) : '');
return {
sourceId: source.id,
type: 'article',
title: cleanHtml(item.title) || item.title,
summary: cleanSummary,
body: cleanBody,
images: item.images,
videos: item.videos,
link: item.link,
publishedAt: item.publishedAt,
fetchedAt: new Date().toISOString(),
tags: [],
geo: source.category.some((c) => c.toLowerCase().startsWith('local')) ? extractGeoTag(source) : null,
embedding: null,
eventId: null,
clusterId: null,
tweet: item.tweet ? { ...item.tweet } : null,
raw: item.raw
};
}
function extractGeoTag(source: Source): string | null {
// Sources assigned to a LocalRegion carry a "Local: X" category by convention in the
// admin panel (see SourcesTab). Real geo-tagging comes from LocalRegion.sourceIds
// membership; this is a lightweight fallback derived from the category label.
const match = source.category.find((c) => c.toLowerCase().startsWith('local'));
if (!match) return null;
const parts = match.split(':');
return parts[1]?.trim().toLowerCase().replace(/\s+/g, '-') ?? null;
}