diff --git a/backend/src/ingestion/adapters/base.ts b/backend/src/ingestion/adapters/base.ts
index e767543..3002913 100644
--- a/backend/src/ingestion/adapters/base.ts
+++ b/backend/src/ingestion/adapters/base.ts
@@ -1,4 +1,4 @@
-import type { Source, ContentItem, TweetMediaItem } from '../../storage/db/types.js';
+import type { Source, ContentItem, TweetMediaItem, QuotedTweet } from '../../storage/db/types.js';
import { cleanHtml, toSummary } from '../clean.js';
export interface FetchedItem {
@@ -10,7 +10,15 @@ 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; media: TweetMediaItem[] };
+ tweet?: {
+ id: string;
+ authorName: string;
+ authorHandle: string;
+ avatarUrl: string | null;
+ media: TweetMediaItem[];
+ repostedByHandle: string | null;
+ quotedTweet: QuotedTweet | null;
+ };
raw: unknown;
}
diff --git a/backend/src/ingestion/adapters/nitter.ts b/backend/src/ingestion/adapters/nitter.ts
index 8e1060f..a13b3d1 100644
--- a/backend/src/ingestion/adapters/nitter.ts
+++ b/backend/src/ingestion/adapters/nitter.ts
@@ -7,10 +7,11 @@
// two outlets' coverage of the same news event does — same reasoning as YouTube.
import Parser from 'rss-parser';
-import type { Source, TweetMediaItem } from '../../storage/db/types.js';
+import type { Source, TweetMediaItem, QuotedTweet } 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';
+import { cleanHtml, toSummary } from '../clean.js';
/** fxtwitter caps a tweet at 4 attached photos/videos/gifs, in tweet display order. */
const MAX_TWEET_MEDIA = 4;
@@ -101,6 +102,46 @@ function extractImageUrl(html: string): string | null {
return html.match(/ ]+src="([^"]+)"/i)?.[1] ?? null;
}
+/**
+ * A bare retweet's RSS title is prefixed "RT by @handle: ..." — confirmed against a real
+ * sample (e.g. `
RT by @rawsalerts: BREAKING: MyPillow CEO... ` with
+ * `@Polymarket `, i.e. dc:creator is already the original author;
+ * only the retweeter's handle is missing from anywhere else in the feed). No blockquote
+ * accompanies a bare retweet — the description is the original tweet's own content directly.
+ */
+function extractRepostedByHandle(title: string | undefined): string | null {
+ return title?.match(/^RT by @(\w+):/i)?.[1] ?? null;
+}
+
+/**
+ * A quote-tweet's RSS description is the quoter's own commentary followed by an ` `
+ * and a `` wrapping the quoted tweet — confirmed against a real sample:
+ * `zerohedge (@zerohedge) ...quoted text...
+ * `.
+ * Nitter carries the quoted tweet's author, text, one image, and permalink fully inline —
+ * no extra fxtwitter call needed to render it as a nested preview.
+ */
+function extractQuotedTweet(descriptionHtml: string): QuotedTweet | null {
+ const blockquoteMatch = descriptionHtml.match(/([\s\S]*?)<\/blockquote>/i);
+ if (!blockquoteMatch) return null;
+ const inner = blockquoteMatch[1];
+
+ const authorMatch = inner.match(/\s*([^<(]+?)\s*\(@([^)]+)\)\s*<\/b>/i);
+ const linkMatch = inner.match(/[\s\S]*?[\s\S]*$/i, '');
+
+ return {
+ authorName: authorMatch[1].trim(),
+ authorHandle: authorMatch[2].trim(),
+ text: toSummary(cleanHtml(beforeFooter), 240),
+ imageUrl: extractImageUrl(inner),
+ link: linkMatch[1]
+ };
+}
+
export const nitterAdapter: SourceAdapter = {
async fetch(source: Source): Promise {
if (!source.url) return [];
@@ -116,16 +157,17 @@ export const nitterAdapter: SourceAdapter = {
continue;
}
- // dc:creator (rss-parser maps it to item.creator) is actually whichever list
- // member's retweet/reply surfaced this item in the feed — NOT the original
- // tweet's author for a retweet. It's only used here to look up the fxtwitter
- // enrichment (fxtwitter needs *a* handle + the tweet ID, and any handle works
- // for that lookup); the author identity actually displayed always comes from
- // the enrichment response below, keyed consistently off the same tweet — so
- // name and handle never end up describing two different people.
+ // dc:creator (rss-parser maps it to item.creator) is reliably the author of the
+ // tweet actually being shown — confirmed against a real "RT by @X: ..." sample,
+ // where dc:creator was the ORIGINAL author, not the retweeter (the retweeter's
+ // handle only ever appears in the title's "RT by @X:" prefix, extracted below).
const rssHandle = (item.creator ?? '').replace(/^@/, '') || 'unknown';
const ownHtml = ownContentHtml(item.content ?? '');
const rssImageUrl = extractImageUrl(ownHtml);
+ const repostedByHandle = extractRepostedByHandle(item.title);
+ // A bare retweet never carries a blockquote (its description IS the original
+ // tweet directly), so this is naturally null whenever repostedByHandle is set.
+ const quotedTweet = extractQuotedTweet(item.content ?? '');
const enrichment = await fetchFxTwitter(rssHandle, tweetId);
@@ -151,7 +193,7 @@ export const nitterAdapter: SourceAdapter = {
videos: [],
link: item.link,
publishedAt,
- tweet: { id: tweetId, authorName, authorHandle: handle, avatarUrl, media },
+ tweet: { id: tweetId, authorName, authorHandle: handle, avatarUrl, media, repostedByHandle, quotedTweet },
raw: { rss: item, fxtwitter: enrichment }
});
}
diff --git a/backend/src/pipeline/publish.ts b/backend/src/pipeline/publish.ts
index 2dd2880..c7b1e45 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, TweetMediaItem } from '../storage/db/types.js';
+import type { GlobalSettings, MergedArticle, ContentItem, TweetMediaItem, QuotedTweet } from '../storage/db/types.js';
const FOLLOW_UP_LOOKBACK_DAYS = 3;
@@ -129,6 +129,16 @@ async function resolveTweetMedia(
return { media: resolved, storedMediaIds };
}
+/** Resolves a quote-tweet's embedded image (if any) through the Nitter media mode, same as any other tweet media. */
+async function resolveQuotedTweet(
+ quoted: QuotedTweet,
+ mode: GlobalSettings['nitterMediaMode']
+): Promise<{ quotedTweet: QuotedTweet; storedMediaId: string | null }> {
+ if (!quoted.imageUrl) return { quotedTweet: quoted, storedMediaId: null };
+ const resolved = await resolveTweetMediaUrl(quoted.imageUrl, mode);
+ return { quotedTweet: { ...quoted, imageUrl: resolved.url }, storedMediaId: resolved.storedMediaId };
+}
+
/**
* 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
@@ -165,12 +175,22 @@ export async function publishDirect(item: ContentItem, settings: GlobalSettings)
}
const resolvedMedia = await resolveTweetMedia(item.tweet.media, settings.nitterMediaMode);
storedMediaIds.push(...resolvedMedia.storedMediaIds);
+
+ let quotedTweet: QuotedTweet | null = null;
+ if (item.tweet.quotedTweet) {
+ const resolvedQuoted = await resolveQuotedTweet(item.tweet.quotedTweet, settings.nitterMediaMode);
+ quotedTweet = resolvedQuoted.quotedTweet;
+ if (resolvedQuoted.storedMediaId) storedMediaIds.push(resolvedQuoted.storedMediaId);
+ }
+
tweet = {
authorName: item.tweet.authorName,
authorHandle: item.tweet.authorHandle,
avatarUrl,
sourceItemId: item.id,
- media: resolvedMedia.media
+ media: resolvedMedia.media,
+ repostedByHandle: item.tweet.repostedByHandle,
+ quotedTweet
};
}
diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts
index d267eea..ad0b61c 100644
--- a/backend/src/storage/db/types.ts
+++ b/backend/src/storage/db/types.ts
@@ -24,6 +24,19 @@ export interface TweetMediaItem {
height: number | null;
}
+/**
+ * The tweet embedded inside a quote-tweet's own — Nitter's RSS carries this
+ * fully inline (author, text, one image, permalink), so no extra fxtwitter call is needed
+ * to render it. Rendered as a smaller frame nested inside the quoting tweet's card.
+ */
+export interface QuotedTweet {
+ authorName: string;
+ authorHandle: string;
+ text: string;
+ imageUrl: string | null;
+ link: string;
+}
+
export interface ContentItem {
id: string;
sourceId: string;
@@ -42,7 +55,17 @@ 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; media: TweetMediaItem[] } | null;
+ tweet: {
+ id: string;
+ authorName: string;
+ authorHandle: string;
+ avatarUrl: string | null;
+ media: TweetMediaItem[];
+ /** Set when this item is a bare retweet — the retweeter's handle, e.g. from RSS "RT by @X:". */
+ repostedByHandle: string | null;
+ /** Set when this item is a quote-tweet — the tweet embedded in its . */
+ quotedTweet: QuotedTweet | null;
+ } | null;
raw: unknown;
}
@@ -60,7 +83,15 @@ export interface MergedArticle {
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 and attached media (see TweetCard.svelte). Never set alongside video. */
- tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string; media: TweetMediaItem[] } | null;
+ tweet: {
+ authorName: string;
+ authorHandle: string;
+ avatarUrl: string | null;
+ sourceItemId: string;
+ media: TweetMediaItem[];
+ repostedByHandle: string | null;
+ quotedTweet: QuotedTweet | null;
+ } | 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 8305076..3435eda 100644
--- a/frontend/src/lib/components/TweetCard.svelte
+++ b/frontend/src/lib/components/TweetCard.svelte
@@ -26,9 +26,19 @@
e.preventDefault();
window.open(resolveMediaUrl(url), '_blank', 'noopener,noreferrer');
}
+
+ // The nested quoted-tweet frame opens its own permalink — a Nitter URL already, not
+ // backend-hosted media, so no resolveMediaUrl needed here.
+ function openQuoted(e: MouseEvent, link: string) {
+ e.preventDefault();
+ window.open(link, '_blank', 'noopener,noreferrer');
+ }
@@ -89,6 +113,12 @@
text-decoration: none;
border-color: var(--border-accent);
}
+ .repost-line {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 10px;
+ }
.meta {
font-size: 11px;
color: var(--text-accent);
@@ -182,6 +212,61 @@
display: block;
background: var(--surface-1);
}
+ /* Nested inside the same outer frame — deliberately lighter-weight than the card
+ itself (no independent hover border, tighter radius, tinted background instead of
+ its own border-forward "card" look) so it reads as inset content, not a second card. */
+ .quote-label {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 6px;
+ }
+ .quoted-card {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: none;
+ border-radius: 8px;
+ padding: 10px;
+ margin-bottom: 8px;
+ background: var(--surface-1);
+ color: inherit;
+ cursor: pointer;
+ }
+ .quoted-author-row {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ margin-bottom: 3px;
+ }
+ .quoted-name {
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+ .quoted-handle {
+ font-size: 12px;
+ color: var(--text-muted);
+ }
+ .quoted-text {
+ font-size: 12.5px;
+ line-height: 1.45;
+ color: var(--text-secondary);
+ white-space: pre-line;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+ .quoted-img {
+ width: 100%;
+ max-height: 140px;
+ object-fit: cover;
+ border-radius: 6px;
+ margin-top: 6px;
+ display: block;
+ background: var(--surface-2);
+ }
.time {
font-size: 11px;
color: var(--text-muted);
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index fd889a1..03b43cf 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -17,13 +17,30 @@ export interface TweetMediaItem {
height: number | null;
}
+/** The tweet embedded in a quote-tweet's own preview — rendered as a smaller nested frame. */
+export interface QuotedTweet {
+ authorName: string;
+ authorHandle: string;
+ text: string;
+ imageUrl: string | null;
+ link: string;
+}
+
export interface MergedArticle {
id: string;
title: string;
body: string;
heroImage: { url: string; sourceItemId: string; selectionReason: string } | null;
video: { url: string; provider?: string; embedUrl?: string; sourceItemId: string } | null;
- tweet: { authorName: string; authorHandle: string; avatarUrl: string | null; sourceItemId: string; media: TweetMediaItem[] } | null;
+ tweet: {
+ authorName: string;
+ authorHandle: string;
+ avatarUrl: string | null;
+ sourceItemId: string;
+ media: TweetMediaItem[];
+ repostedByHandle: string | null;
+ quotedTweet: QuotedTweet | null;
+ } | null;
category: string[];
geo: string | null;
eventId: string | null;