d4ab690061
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
59 lines
2.3 KiB
TypeScript
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;
|
|
}
|