From 7f181384efb8db48e6491a8f606992a70791151b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 18:04:29 +0000 Subject: [PATCH] Add repost flag and nested quoted-tweet frame to TweetCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nitter's RSS marks a bare retweet with a "RT by @handle:" prefix on the item's (dc:creator is already the original author, not the retweeter — confirmed against a real sample). TweetCard now shows a "🔁 Reposted by @handle" line above an otherwise-unchanged card. A quote-tweet's RSS description carries the embedded tweet fully inline in a <blockquote> (author, text, one image, permalink) — no extra fxtwitter call needed. TweetCard renders it as a smaller frame nested inside the same outer card, below the quoting tweet's own text and media, labeled "â†Šī¸ Replying to @handle" per how this reads to a visitor even though it's technically Nitter's quote-tweet representation. Clicking it opens that tweet's own permalink, independent of the outer card's link. Both parsers verified against the real sample RSS (Polymarket/ rawsalerts retweet, Goldman/zerohedge quote-tweet) and against the live publishDirect pipeline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014c1L8ghNBFjfiH64UMViP8 --- backend/src/ingestion/adapters/base.ts | 12 ++- backend/src/ingestion/adapters/nitter.ts | 60 +++++++++++--- backend/src/pipeline/publish.ts | 24 +++++- backend/src/storage/db/types.ts | 35 +++++++- frontend/src/lib/components/TweetCard.svelte | 85 ++++++++++++++++++++ frontend/src/lib/types.ts | 19 ++++- 6 files changed, 219 insertions(+), 16 deletions(-) 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(/<img[^>]+src="([^"]+)"/i)?.[1] ?? null; } +/** + * A bare retweet's RSS title is prefixed "RT by @handle: ..." — confirmed against a real + * sample (e.g. `<title>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(/