diff --git a/CHANGELOG.md b/CHANGELOG.md index df5755f..508437e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli. - `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0. +- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0. ### Fixed - macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst. diff --git a/src/cli/shared.ts b/src/cli/shared.ts index 806de34..3f6bc3d 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -318,9 +318,66 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn console.log(opts.emptyMessage ?? 'No tweets found.'); return; } + const useEmoji = output.emoji && !output.plain; + const articleLabel = useEmoji ? '📰' : 'Article:'; + const mediaLabel = (type: 'photo' | 'video' | 'animated_gif'): string => { + if (useEmoji) { + return type === 'video' ? '🎬' : type === 'animated_gif' ? '🔄' : '🖼️'; + } + return type === 'video' ? 'VIDEO:' : type === 'animated_gif' ? 'GIF:' : 'PHOTO:'; + }; + const quotePrefix = useEmoji ? { top: '┌─', mid: '│ ', bot: '└─' } : { top: '> ', mid: '> ', bot: '> ' }; + for (const tweet of tweets) { console.log(`\n@${tweet.author.username} (${tweet.author.name}):`); - console.log(tweet.text); + + // Display tweet text, with article indicator if present + if (tweet.article) { + // Full body mode: text starts with article title (from extractArticleText) + // Preview mode: text is short tweet intro that doesn't start with title + const hasFullBody = tweet.text.startsWith(tweet.article.title); + if (hasFullBody) { + console.log(`${articleLabel} ${tweet.text}`); + } else { + console.log(`${articleLabel} ${tweet.article.title}`); + if (tweet.article.previewText) { + console.log(` ${tweet.article.previewText}`); + } + } + } else { + console.log(tweet.text); + } + + // Display media attachments + if (tweet.media && tweet.media.length > 0) { + for (const m of tweet.media) { + console.log(`${mediaLabel(m.type)} ${m.url}`); + } + } + + // Display quoted tweet + if (tweet.quotedTweet) { + console.log(`${quotePrefix.top} QT @${tweet.quotedTweet.author.username}:`); + const qtText = tweet.quotedTweet.article + ? `${articleLabel} ${tweet.quotedTweet.article.title}` + : tweet.quotedTweet.text; + // Indent and truncate quoted tweet text + const maxLen = 280; + const truncated = qtText.length > maxLen ? `${qtText.slice(0, maxLen)}...` : qtText; + for (const line of truncated.split('\n').slice(0, 4)) { + console.log(`${quotePrefix.mid}${line}`); + } + // Display quoted tweet media + if (tweet.quotedTweet.media && tweet.quotedTweet.media.length > 0) { + for (const m of tweet.quotedTweet.media) { + console.log(`${quotePrefix.mid}${mediaLabel(m.type)} ${m.url}`); + } + } + console.log( + `${quotePrefix.bot} https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`, + ); + } + if (tweet.createdAt) { console.log(`${l('date')}${tweet.createdAt}`); } diff --git a/src/commands/read.ts b/src/commands/read.ts index 3a1056d..5a5ebb6 100644 --- a/src/commands/read.ts +++ b/src/commands/read.ts @@ -36,11 +36,7 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { if (cmdOpts.json || cmdOpts.jsonFull) { console.log(JSON.stringify(result.tweet, null, 2)); } else { - console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); - console.log(result.tweet.text); - if (result.tweet.createdAt) { - console.log(`\n${ctx.l('date')}${result.tweet.createdAt}`); - } + ctx.printTweets([result.tweet], { showSeparator: false }); console.log(formatStatsLine(result.tweet, ctx.getOutput())); } } else { diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index b0118a7..c27c80c 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -80,9 +80,12 @@ export type GraphqlTweetResult = { }; }; article?: { + title?: string; + preview_text?: string; article_results?: { result?: { title?: string; + preview_text?: string; plain_text?: string; text?: string; richtext?: { @@ -129,7 +132,6 @@ export type GraphqlTweetResult = { }>; }; }; - title?: string; plain_text?: string; text?: string; richtext?: { @@ -236,6 +238,11 @@ export interface TweetData { quotedTweet?: TweetData; // Media attachments (photos, videos, GIFs) media?: TweetMedia[]; + // Article metadata (for Twitter Articles / long-form posts) + article?: { + title: string; + previewText?: string; + }; // Raw GraphQL tweet result (only when includeRaw is enabled) _raw?: GraphqlTweetResult; } diff --git a/src/lib/twitter-client-utils.ts b/src/lib/twitter-client-utils.ts index 7730396..daf049a 100644 --- a/src/lib/twitter-client-utils.ts +++ b/src/lib/twitter-client-utils.ts @@ -153,6 +153,26 @@ export function extractTweetText(result: GraphqlTweetResult | undefined): string return extractArticleText(result) ?? extractNoteTweetText(result) ?? firstText(result?.legacy?.full_text); } +export function extractArticleMetadata( + result: GraphqlTweetResult | undefined, +): { title: string; previewText?: string } | undefined { + const article = result?.article; + if (!article) { + return undefined; + } + + const articleResult = article.article_results?.result ?? article; + const title = firstText(articleResult.title, article.title); + if (!title) { + return undefined; + } + + // preview_text is available in home timeline responses + const previewText = firstText(articleResult.preview_text, article.preview_text); + + return { title, previewText }; +} + export function extractMedia(result: GraphqlTweetResult | undefined): TweetMedia[] | undefined { // Prefer extended_entities (has video info), fall back to entities const rawMedia = result?.legacy?.extended_entities?.media ?? result?.legacy?.entities?.media; @@ -261,6 +281,7 @@ export function mapTweetResult( } const media = extractMedia(result); + const article = extractArticleMetadata(result); const tweetData: TweetData = { id: result.rest_id, @@ -278,6 +299,7 @@ export function mapTweetResult( authorId: userId, quotedTweet, media, + article, }; if (includeRaw) { diff --git a/tests/extract-article-metadata.test.ts b/tests/extract-article-metadata.test.ts new file mode 100644 index 0000000..8bbb476 --- /dev/null +++ b/tests/extract-article-metadata.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js'; +import { extractArticleMetadata } from '../src/lib/twitter-client-utils.js'; + +describe('extractArticleMetadata', () => { + it('returns undefined when no result', () => { + expect(extractArticleMetadata(undefined)).toBeUndefined(); + }); + + it('returns undefined when no article field', () => { + const result: GraphqlTweetResult = { + rest_id: '123', + legacy: { full_text: 'Hello world' }, + }; + expect(extractArticleMetadata(result)).toBeUndefined(); + }); + + it('returns undefined when article has no title', () => { + const result: GraphqlTweetResult = { + rest_id: '123', + article: { + article_results: { + result: {}, + }, + }, + }; + expect(extractArticleMetadata(result)).toBeUndefined(); + }); + + it('extracts title from article_results.result', () => { + const result: GraphqlTweetResult = { + rest_id: '123', + article: { + article_results: { + result: { + title: 'Building Context Graphs for GTM', + }, + }, + }, + }; + + const metadata = extractArticleMetadata(result); + expect(metadata).toEqual({ + title: 'Building Context Graphs for GTM', + previewText: undefined, + }); + }); + + it('extracts title and preview_text from article_results.result', () => { + const result: GraphqlTweetResult = { + rest_id: '123', + article: { + article_results: { + result: { + title: 'Building Context Graphs for GTM', + preview_text: + 'Foundation Capital recently argued that one of the next trillion-dollar opportunities in AI will come from context graphs.', + }, + }, + }, + } as GraphqlTweetResult; + + const metadata = extractArticleMetadata(result); + expect(metadata).toEqual({ + title: 'Building Context Graphs for GTM', + previewText: + 'Foundation Capital recently argued that one of the next trillion-dollar opportunities in AI will come from context graphs.', + }); + }); + + it('falls back to article.title when article_results.result.title missing', () => { + const result: GraphqlTweetResult = { + rest_id: '123', + article: { + title: 'Fallback Title', + article_results: { + result: {}, + }, + }, + }; + + const metadata = extractArticleMetadata(result); + expect(metadata).toEqual({ + title: 'Fallback Title', + previewText: undefined, + }); + }); +}); diff --git a/tests/map-tweet-article.test.ts b/tests/map-tweet-article.test.ts new file mode 100644 index 0000000..f20d3aa --- /dev/null +++ b/tests/map-tweet-article.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js'; +import { mapTweetResult } from '../src/lib/twitter-client-utils.js'; + +describe('mapTweetResult article', () => { + const makeUserResult = () => ({ + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'alice', name: 'Alice' }, + }, + }, + }, + }); + + it('includes extracted article metadata on mapped tweet', () => { + const result: GraphqlTweetResult = { + rest_id: '1', + legacy: { + full_text: 'Check out my article', + }, + article: { + article_results: { + result: { + title: 'Building Context Graphs for GTM', + preview_text: 'Foundation Capital recently argued...', + }, + }, + }, + ...makeUserResult(), + } as GraphqlTweetResult; + + const mapped = mapTweetResult(result, 0); + expect(mapped).toBeDefined(); + expect(mapped?.article).toEqual({ + title: 'Building Context Graphs for GTM', + previewText: 'Foundation Capital recently argued...', + }); + }); + + it('omits article when none present', () => { + const result: GraphqlTweetResult = { + rest_id: '2', + legacy: { full_text: 'no article' }, + ...makeUserResult(), + }; + + const mapped = mapTweetResult(result, 0); + expect(mapped?.article).toBeUndefined(); + }); + + it('includes article with only title when no preview_text', () => { + const result: GraphqlTweetResult = { + rest_id: '3', + legacy: { full_text: 'article link' }, + article: { + article_results: { + result: { + title: 'My Article Title', + }, + }, + }, + ...makeUserResult(), + }; + + const mapped = mapTweetResult(result, 0); + expect(mapped?.article).toEqual({ + title: 'My Article Title', + previewText: undefined, + }); + }); +});