Merge pull request #32 from odysseus0/feat/text-output
feat: improve text output with article preview, quote tweets, and media
This commit is contained in:
@@ -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.
|
||||
|
||||
+58
-1
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user