Merge pull request #13 from Salastil/claude/nitter-rss-tweets

Claude/nitter rss tweets
This commit is contained in:
Salastil
2026-07-23 14:26:15 -04:00
committed by GitHub
6 changed files with 219 additions and 15 deletions
+10 -2
View File
@@ -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;
}
+55 -8
View File
@@ -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 } 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...</title>` with
* `<dc:creator>@Polymarket</dc:creator>`, 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 `<hr/>`
* and a `<blockquote>` wrapping the quoted tweet — confirmed against a real sample:
* `<blockquote><b>zerohedge (@zerohedge)</b><p>...quoted text...<img .../></p>
* <footer>— <cite><a href="https://.../status/...">...</a></cite></footer></blockquote>`.
* 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(/<blockquote>([\s\S]*?)<\/blockquote>/i);
if (!blockquoteMatch) return null;
const inner = blockquoteMatch[1];
const authorMatch = inner.match(/<b>\s*([^<(]+?)\s*\(@([^)]+)\)\s*<\/b>/i);
const linkMatch = inner.match(/<footer>[\s\S]*?<a href="([^"]+)"/i);
if (!authorMatch || !linkMatch) return null;
const afterAuthor = inner.slice((authorMatch.index ?? 0) + authorMatch[0].length);
const beforeFooter = afterAuthor.replace(/<footer>[\s\S]*$/i, '');
return {
authorName: authorMatch[1].trim(),
authorHandle: authorMatch[2].trim(),
text: cleanHtml(beforeFooter),
imageUrl: extractImageUrl(inner),
link: linkMatch[1]
};
}
export const nitterAdapter: SourceAdapter = {
async fetch(source: Source): Promise<FetchedItem[]> {
if (!source.url) return [];
@@ -116,16 +157,22 @@ export const nitterAdapter: SourceAdapter = {
continue;
}
// dc:creator is reliably the author of this item's own tweet text (rss-parser
// maps it to item.creator) — for a retweet, that's the original tweet's author,
// not whichever list member's retweet surfaced it in this feed.
const handle = (item.creator ?? '').replace(/^@/, '') || 'unknown';
// 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(handle, tweetId);
const enrichment = await fetchFxTwitter(rssHandle, tweetId);
const authorName = enrichment?.author?.name ?? handle;
const authorName = enrichment?.author?.name ?? rssHandle;
const handle = enrichment?.author?.screen_name ?? rssHandle;
const avatarUrl = enrichment?.author?.avatar_url ?? null;
const text = enrichment?.text ?? ownHtml;
// Enrichment failed (or came back with no media) — fall back to the RSS
@@ -146,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 }
});
}
+22 -2
View File
@@ -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
};
}
+33 -2
View File
@@ -24,6 +24,19 @@ export interface TweetMediaItem {
height: number | null;
}
/**
* The tweet embedded inside a quote-tweet's own <blockquote> — 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 <blockquote>. */
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;
@@ -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');
}
</script>
<a class="tweet-card" href={tweetUrl} target="_blank" rel="noreferrer">
{#if article.tweet?.repostedByHandle}
<div class="repost-line">🔁 Reposted by @{article.tweet.repostedByHandle}</div>
{/if}
<div class="meta">
<span>{article.category[0] ?? ''}</span>
<span>&middot;</span>
@@ -72,6 +82,20 @@
{/each}
</div>
{/if}
{#if article.tweet?.quotedTweet}
{@const quoted = article.tweet.quotedTweet}
<div class="quote-label">↩️ Replying to @{quoted.authorHandle}</div>
<button type="button" class="quoted-card" onclick={(e) => openQuoted(e, quoted.link)}>
<div class="quoted-author-row">
<span class="quoted-name">{quoted.authorName}</span>
<span class="quoted-handle">@{quoted.authorHandle}</span>
</div>
<div class="quoted-text">{quoted.text}</div>
{#if quoted.imageUrl}
<img class="quoted-img" src={resolveMediaUrl(quoted.imageUrl)} alt="" loading="lazy" />
{/if}
</button>
{/if}
<div class="time">{timeAgo(article.publishedAt)} &middot; {exactTime(article.publishedAt)}</div>
</a>
@@ -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,57 @@
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;
}
.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);
+18 -1
View File
@@ -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;