feat: add --json-full flag to include raw API response in _raw field
Add a new --json-full flag to tweet-fetching commands that outputs JSON with an additional _raw field containing the full GraphQL response. This provides power users access to additional data not exposed in the curated TweetData interface (media, entities, hashtags, cards, etc.) while maintaining backward compatibility with --json. Supported commands: - read, replies, thread (TweetDetail API) - search, mentions (SearchTimeline API) - bookmarks (Bookmarks API) - likes (Likes API) Not supported: following, followers (uses mixed GraphQL/REST fallback with different response structures) Implementation: - Add _raw?: GraphqlTweetResult optional field to TweetData type - Add includeRaw option to mapTweetResult() and parseTweetsFromInstructions() - Add TweetFetchOptions, SearchFetchOptions, TimelineFetchOptions interfaces - Update client methods to accept options parameter - Add --json-full flag to CLI commands - Update help text to document the new flag - Add comprehensive test coverage (16 tests) Co-authored-by: Christian Catalan <[email protected]>
This commit is contained in:
committed by
Peter Steinberger
co-authored by
Christian Catalan
parent
42abae9c15
commit
3815ebfa03
@@ -82,6 +82,8 @@ export function createProgram(ctx: CliContext): Command {
|
|||||||
formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'),
|
formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'),
|
||||||
].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted(
|
].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted(
|
||||||
` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, query-ids`,
|
` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, query-ids`,
|
||||||
|
)}\n${ctx.colors.muted(
|
||||||
|
` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`,
|
||||||
)}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`,
|
)}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
|||||||
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
|
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
|
||||||
.option('--folder-id <id>', 'Bookmark folder (collection) id')
|
.option('--folder-id <id>', 'Bookmark folder (collection) id')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
|
.action(async (cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean; folderId?: string }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||||
@@ -32,13 +33,14 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
|||||||
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
|
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||||
const result = folderId
|
const result = folderId
|
||||||
? await client.getBookmarkFolderTimeline(folderId, count)
|
? await client.getBookmarkFolderTimeline(folderId, count, { includeRaw })
|
||||||
: await client.getBookmarks(count);
|
: await client.getBookmarks(count, { includeRaw });
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
|
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
|
||||||
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
|
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage });
|
||||||
} else {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
+18
-9
@@ -9,7 +9,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
.description('Read/fetch a tweet by ID or URL')
|
.description('Read/fetch a tweet by ID or URL')
|
||||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean; jsonFull?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||||
@@ -28,10 +29,11 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.getTweet(tweetId);
|
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||||
|
const result = await client.getTweet(tweetId, { includeRaw });
|
||||||
|
|
||||||
if (result.success && result.tweet) {
|
if (result.success && result.tweet) {
|
||||||
if (cmdOpts.json) {
|
if (cmdOpts.json || cmdOpts.jsonFull) {
|
||||||
console.log(JSON.stringify(result.tweet, null, 2));
|
console.log(JSON.stringify(result.tweet, null, 2));
|
||||||
} else {
|
} else {
|
||||||
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
|
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
|
||||||
@@ -52,7 +54,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
.description('List replies to a tweet (by ID or URL)')
|
.description('List replies to a tweet (by ID or URL)')
|
||||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean; jsonFull?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||||
@@ -70,10 +73,11 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.getReplies(tweetId);
|
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||||
|
const result = await client.getReplies(tweetId, { includeRaw });
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
|
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage: 'No replies found.' });
|
||||||
} else {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -85,7 +89,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
.description('Show the full conversation thread containing the tweet')
|
.description('Show the full conversation thread containing the tweet')
|
||||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean; jsonFull?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||||
@@ -103,10 +108,14 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.getThread(tweetId);
|
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||||
|
const result = await client.getThread(tweetId, { includeRaw });
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
|
ctx.printTweets(result.tweets, {
|
||||||
|
json: cmdOpts.json || cmdOpts.jsonFull,
|
||||||
|
emptyMessage: 'No thread tweets found.',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
+10
-6
@@ -10,7 +10,8 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
|
|||||||
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
|
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
|
||||||
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
|
.action(async (query: string, cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||||
@@ -28,10 +29,11 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.search(query, count);
|
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||||
|
const result = await client.search(query, count, { includeRaw });
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
|
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage: 'No tweets found.' });
|
||||||
} else {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Search failed: ${result.error}`);
|
console.error(`${ctx.p('err')}Search failed: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -44,7 +46,8 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
|
|||||||
.option('-u, --user <handle>', 'User handle (e.g. @steipete)')
|
.option('-u, --user <handle>', 'User handle (e.g. @steipete)')
|
||||||
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
|
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean; jsonFull?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||||
@@ -84,10 +87,11 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await client.search(query, count);
|
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||||
|
const result = await client.search(query, count, { includeRaw });
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage: 'No mentions found.' });
|
||||||
} else {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch mentions: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch mentions: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
|
|||||||
.description('Get your liked tweets')
|
.description('Get your liked tweets')
|
||||||
.option('-n, --count <number>', 'Number of likes to fetch', '20')
|
.option('-n, --count <number>', 'Number of likes to fetch', '20')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
|
.action(async (cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
|
||||||
@@ -148,10 +149,14 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||||
const result = await client.getLikes(count);
|
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||||
|
const result = await client.getLikes(count, { includeRaw });
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
|
ctx.printTweets(result.tweets, {
|
||||||
|
json: cmdOpts.json || cmdOpts.jsonFull,
|
||||||
|
emptyMessage: 'No liked tweets found.',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ export {
|
|||||||
type TwitterClientOptions,
|
type TwitterClientOptions,
|
||||||
type TwitterUser,
|
type TwitterUser,
|
||||||
} from './twitter-client.js';
|
} from './twitter-client.js';
|
||||||
|
export type { SearchFetchOptions } from './twitter-client-search.js';
|
||||||
|
export type { TimelineFetchOptions } from './twitter-client-timelines.js';
|
||||||
|
export type { TweetFetchOptions } from './twitter-client-tweet-detail.js';
|
||||||
export type { TweetResult, UploadMediaResult } from './twitter-client-types.js';
|
export type { TweetResult, UploadMediaResult } from './twitter-client-types.js';
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ import { buildSearchFeatures } from './twitter-client-features.js';
|
|||||||
import type { SearchResult, TweetData } from './twitter-client-types.js';
|
import type { SearchResult, TweetData } from './twitter-client-types.js';
|
||||||
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
|
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
|
||||||
|
|
||||||
|
/** Options for search methods */
|
||||||
|
export interface SearchFetchOptions {
|
||||||
|
/** Include raw GraphQL response in `_raw` field */
|
||||||
|
includeRaw?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TwitterClientSearchMethods {
|
export interface TwitterClientSearchMethods {
|
||||||
search(query: string, count?: number): Promise<SearchResult>;
|
search(query: string, count?: number, options?: SearchFetchOptions): Promise<SearchResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>(
|
export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||||
@@ -20,7 +26,8 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
|
|||||||
/**
|
/**
|
||||||
* Search for tweets matching a query
|
* Search for tweets matching a query
|
||||||
*/
|
*/
|
||||||
async search(query: string, count = 20): Promise<SearchResult> {
|
async search(query: string, count = 20, options: SearchFetchOptions = {}): Promise<SearchResult> {
|
||||||
|
const { includeRaw = false } = options;
|
||||||
const features = buildSearchFeatures();
|
const features = buildSearchFeatures();
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -113,7 +120,7 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions;
|
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions;
|
||||||
const pageTweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
|
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
|
||||||
const nextCursor = extractCursorFromInstructions(instructions);
|
const nextCursor = extractCursorFromInstructions(instructions);
|
||||||
|
|
||||||
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
|
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import { buildBookmarksFeatures, buildLikesFeatures } from './twitter-client-fea
|
|||||||
import type { GraphqlTweetResult, SearchResult } from './twitter-client-types.js';
|
import type { GraphqlTweetResult, SearchResult } from './twitter-client-types.js';
|
||||||
import { parseTweetsFromInstructions } from './twitter-client-utils.js';
|
import { parseTweetsFromInstructions } from './twitter-client-utils.js';
|
||||||
|
|
||||||
|
/** Options for timeline fetch methods */
|
||||||
|
export interface TimelineFetchOptions {
|
||||||
|
/** Include raw GraphQL response in `_raw` field */
|
||||||
|
includeRaw?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TwitterClientTimelineMethods {
|
export interface TwitterClientTimelineMethods {
|
||||||
getBookmarks(count?: number): Promise<SearchResult>;
|
getBookmarks(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
|
||||||
getLikes(count?: number): Promise<SearchResult>;
|
getLikes(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
|
||||||
getBookmarkFolderTimeline(folderId: string, count?: number): Promise<SearchResult>;
|
getBookmarkFolderTimeline(folderId: string, count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withTimelines<TBase extends AbstractConstructor<TwitterClientBase>>(
|
export function withTimelines<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||||
@@ -37,7 +43,8 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
/**
|
/**
|
||||||
* Get the authenticated user's bookmarks
|
* Get the authenticated user's bookmarks
|
||||||
*/
|
*/
|
||||||
async getBookmarks(count = 20): Promise<SearchResult> {
|
async getBookmarks(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
|
||||||
|
const { includeRaw = false } = options;
|
||||||
const variables = {
|
const variables = {
|
||||||
count,
|
count,
|
||||||
includePromotedContent: false,
|
includePromotedContent: false,
|
||||||
@@ -104,7 +111,7 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
}
|
}
|
||||||
|
|
||||||
const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions;
|
const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions;
|
||||||
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
|
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
|
||||||
|
|
||||||
return { success: true as const, tweets, had404 };
|
return { success: true as const, tweets, had404 };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -135,7 +142,8 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
/**
|
/**
|
||||||
* Get the authenticated user's liked tweets
|
* Get the authenticated user's liked tweets
|
||||||
*/
|
*/
|
||||||
async getLikes(count = 20): Promise<SearchResult> {
|
async getLikes(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
|
||||||
|
const { includeRaw = false } = options;
|
||||||
const userResult = await this.getCurrentUser();
|
const userResult = await this.getCurrentUser();
|
||||||
if (!userResult.success || !userResult.user) {
|
if (!userResult.success || !userResult.user) {
|
||||||
return { success: false, error: userResult.error ?? 'Could not determine current user' };
|
return { success: false, error: userResult.error ?? 'Could not determine current user' };
|
||||||
@@ -212,7 +220,7 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
}
|
}
|
||||||
|
|
||||||
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
|
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
|
||||||
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
|
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
|
||||||
|
|
||||||
return { success: true as const, tweets, had404 };
|
return { success: true as const, tweets, had404 };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -243,7 +251,12 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
/**
|
/**
|
||||||
* Get the authenticated user's bookmark folder timeline
|
* Get the authenticated user's bookmark folder timeline
|
||||||
*/
|
*/
|
||||||
async getBookmarkFolderTimeline(folderId: string, count = 20): Promise<SearchResult> {
|
async getBookmarkFolderTimeline(
|
||||||
|
folderId: string,
|
||||||
|
count = 20,
|
||||||
|
options: TimelineFetchOptions = {},
|
||||||
|
): Promise<SearchResult> {
|
||||||
|
const { includeRaw = false } = options;
|
||||||
const variablesWithCount = {
|
const variablesWithCount = {
|
||||||
bookmark_collection_id: folderId,
|
bookmark_collection_id: folderId,
|
||||||
includePromotedContent: true,
|
includePromotedContent: true,
|
||||||
@@ -313,7 +326,7 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
}
|
}
|
||||||
|
|
||||||
const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions;
|
const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions;
|
||||||
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
|
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
|
||||||
|
|
||||||
return { success: true as const, tweets, had404 };
|
return { success: true as const, tweets, had404 };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ import {
|
|||||||
parseTweetsFromInstructions,
|
parseTweetsFromInstructions,
|
||||||
} from './twitter-client-utils.js';
|
} from './twitter-client-utils.js';
|
||||||
|
|
||||||
|
/** Options for tweet fetching methods */
|
||||||
|
export interface TweetFetchOptions {
|
||||||
|
/** Include raw GraphQL response in `_raw` field */
|
||||||
|
includeRaw?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TwitterClientTweetDetailMethods {
|
export interface TwitterClientTweetDetailMethods {
|
||||||
getTweet(tweetId: string): Promise<GetTweetResult>;
|
getTweet(tweetId: string, options?: TweetFetchOptions): Promise<GetTweetResult>;
|
||||||
getReplies(tweetId: string): Promise<SearchResult>;
|
getReplies(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
|
||||||
getThread(tweetId: string): Promise<SearchResult>;
|
getThread(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withTweetDetails<TBase extends AbstractConstructor<TwitterClientBase>>(
|
export function withTweetDetails<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||||
@@ -246,7 +252,8 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
/**
|
/**
|
||||||
* Get tweet details by ID
|
* Get tweet details by ID
|
||||||
*/
|
*/
|
||||||
async getTweet(tweetId: string): Promise<GetTweetResult> {
|
async getTweet(tweetId: string, options: TweetFetchOptions = {}): Promise<GetTweetResult> {
|
||||||
|
const { includeRaw = false } = options;
|
||||||
const response = await this.fetchTweetDetail(tweetId);
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
return response;
|
return response;
|
||||||
@@ -271,7 +278,7 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
tweetId,
|
tweetId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mapped = mapTweetResult(tweetResult, this.quoteDepth);
|
const mapped = mapTweetResult(tweetResult, { quoteDepth: this.quoteDepth, includeRaw });
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
if (tweetResult?.article) {
|
if (tweetResult?.article) {
|
||||||
const title = firstText(tweetResult.article.article_results?.result?.title, tweetResult.article.title);
|
const title = firstText(tweetResult.article.article_results?.result?.title, tweetResult.article.title);
|
||||||
@@ -294,14 +301,15 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
/**
|
/**
|
||||||
* Get replies to a tweet by ID
|
* Get replies to a tweet by ID
|
||||||
*/
|
*/
|
||||||
async getReplies(tweetId: string): Promise<SearchResult> {
|
async getReplies(tweetId: string, options: TweetFetchOptions = {}): Promise<SearchResult> {
|
||||||
|
const { includeRaw = false } = options;
|
||||||
const response = await this.fetchTweetDetail(tweetId);
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
||||||
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
|
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
|
||||||
const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId);
|
const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId);
|
||||||
|
|
||||||
return { success: true, tweets: replies };
|
return { success: true, tweets: replies };
|
||||||
@@ -310,14 +318,15 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
/**
|
/**
|
||||||
* Get full conversation thread for a tweet ID
|
* Get full conversation thread for a tweet ID
|
||||||
*/
|
*/
|
||||||
async getThread(tweetId: string): Promise<SearchResult> {
|
async getThread(tweetId: string, options: TweetFetchOptions = {}): Promise<SearchResult> {
|
||||||
|
const { includeRaw = false } = options;
|
||||||
const response = await this.fetchTweetDetail(tweetId);
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
||||||
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
|
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
|
||||||
|
|
||||||
const target = tweets.find((t) => t.id === tweetId);
|
const target = tweets.find((t) => t.id === tweetId);
|
||||||
const rootId = target?.conversationId || tweetId;
|
const rootId = target?.conversationId || tweetId;
|
||||||
|
|||||||
@@ -184,6 +184,9 @@ export interface TweetData {
|
|||||||
inReplyToStatusId?: string;
|
inReplyToStatusId?: string;
|
||||||
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
|
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
|
||||||
quotedTweet?: TweetData;
|
quotedTweet?: TweetData;
|
||||||
|
// Raw GraphQL response (included when --json-full is used).
|
||||||
|
// Structure may change as Twitter's API evolves.
|
||||||
|
_raw?: GraphqlTweetResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetTweetResult {
|
export interface GetTweetResult {
|
||||||
|
|||||||
@@ -163,7 +163,19 @@ export function unwrapTweetResult(result: GraphqlTweetResult | undefined): Graph
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth: number): TweetData | undefined {
|
export interface MapTweetResultOptions {
|
||||||
|
quoteDepth: number;
|
||||||
|
includeRaw?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapTweetResult(
|
||||||
|
result: GraphqlTweetResult | undefined,
|
||||||
|
quoteDepthOrOptions: number | MapTweetResultOptions,
|
||||||
|
): TweetData | undefined {
|
||||||
|
const options: MapTweetResultOptions =
|
||||||
|
typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
|
||||||
|
const { quoteDepth, includeRaw = false } = options;
|
||||||
|
|
||||||
const userResult = result?.core?.user_results?.result;
|
const userResult = result?.core?.user_results?.result;
|
||||||
const userLegacy = userResult?.legacy;
|
const userLegacy = userResult?.legacy;
|
||||||
const userCore = userResult?.core;
|
const userCore = userResult?.core;
|
||||||
@@ -183,11 +195,11 @@ export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDept
|
|||||||
if (quoteDepth > 0) {
|
if (quoteDepth > 0) {
|
||||||
const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
|
const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
|
||||||
if (quotedResult) {
|
if (quotedResult) {
|
||||||
quotedTweet = mapTweetResult(quotedResult, quoteDepth - 1);
|
quotedTweet = mapTweetResult(quotedResult, { quoteDepth: quoteDepth - 1, includeRaw });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const tweetData: TweetData = {
|
||||||
id: result.rest_id,
|
id: result.rest_id,
|
||||||
text,
|
text,
|
||||||
createdAt: result.legacy?.created_at,
|
createdAt: result.legacy?.created_at,
|
||||||
@@ -203,6 +215,12 @@ export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDept
|
|||||||
authorId: userId,
|
authorId: userId,
|
||||||
quotedTweet,
|
quotedTweet,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (includeRaw) {
|
||||||
|
tweetData._raw = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tweetData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findTweetInInstructions(
|
export function findTweetInInstructions(
|
||||||
@@ -294,6 +312,11 @@ export function collectTweetResultsFromEntry(entry: {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParseTweetsOptions {
|
||||||
|
quoteDepth: number;
|
||||||
|
includeRaw?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseTweetsFromInstructions(
|
export function parseTweetsFromInstructions(
|
||||||
instructions:
|
instructions:
|
||||||
| Array<{
|
| Array<{
|
||||||
@@ -336,8 +359,12 @@ export function parseTweetsFromInstructions(
|
|||||||
}>;
|
}>;
|
||||||
}>
|
}>
|
||||||
| undefined,
|
| undefined,
|
||||||
quoteDepth: number,
|
quoteDepthOrOptions: number | ParseTweetsOptions,
|
||||||
): TweetData[] {
|
): TweetData[] {
|
||||||
|
const options: ParseTweetsOptions =
|
||||||
|
typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
|
||||||
|
const { quoteDepth, includeRaw = false } = options;
|
||||||
|
|
||||||
const tweets: TweetData[] = [];
|
const tweets: TweetData[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
@@ -345,7 +372,7 @@ export function parseTweetsFromInstructions(
|
|||||||
for (const entry of instruction.entries ?? []) {
|
for (const entry of instruction.entries ?? []) {
|
||||||
const results = collectTweetResultsFromEntry(entry);
|
const results = collectTweetResultsFromEntry(entry);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
const mapped = mapTweetResult(result, quoteDepth);
|
const mapped = mapTweetResult(result, { quoteDepth, includeRaw });
|
||||||
if (!mapped || seen.has(mapped.id)) {
|
if (!mapped || seen.has(mapped.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ describe('root help output', () => {
|
|||||||
|
|
||||||
expect(help).toContain('bird <tweet-id-or-url>');
|
expect(help).toContain('bird <tweet-id-or-url>');
|
||||||
expect(help).toContain('--json');
|
expect(help).toContain('--json');
|
||||||
|
expect(help).toContain('--json-full');
|
||||||
|
expect(help).toContain('_raw');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows global options in subcommand help', () => {
|
it('shows global options in subcommand help', () => {
|
||||||
@@ -48,4 +50,51 @@ describe('root help output', () => {
|
|||||||
expect(help).toContain('--timeout');
|
expect(help).toContain('--timeout');
|
||||||
expect(help).toContain('--quote-depth');
|
expect(help).toContain('--quote-depth');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows --json-full option in read command help', () => {
|
||||||
|
const ctx = createCliContext([]);
|
||||||
|
const program = createProgram(ctx);
|
||||||
|
const cmd = program.commands.find((c) => c.name() === 'read');
|
||||||
|
if (!cmd) {
|
||||||
|
throw new Error('Expected "read" command to be registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
let help = '';
|
||||||
|
const output = {
|
||||||
|
writeOut: (s) => {
|
||||||
|
help += s;
|
||||||
|
},
|
||||||
|
writeErr: () => {},
|
||||||
|
};
|
||||||
|
program.configureOutput(output);
|
||||||
|
cmd.configureOutput(output);
|
||||||
|
|
||||||
|
cmd.outputHelp();
|
||||||
|
|
||||||
|
expect(help).toContain('--json-full');
|
||||||
|
expect(help).toContain('_raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows --json-full option in search command help', () => {
|
||||||
|
const ctx = createCliContext([]);
|
||||||
|
const program = createProgram(ctx);
|
||||||
|
const cmd = program.commands.find((c) => c.name() === 'search');
|
||||||
|
if (!cmd) {
|
||||||
|
throw new Error('Expected "search" command to be registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
let help = '';
|
||||||
|
const output = {
|
||||||
|
writeOut: (s) => {
|
||||||
|
help += s;
|
||||||
|
},
|
||||||
|
writeErr: () => {},
|
||||||
|
};
|
||||||
|
program.configureOutput(output);
|
||||||
|
cmd.configureOutput(output);
|
||||||
|
|
||||||
|
cmd.outputHelp();
|
||||||
|
|
||||||
|
expect(help).toContain('--json-full');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TwitterClient } from '../src/lib/twitter-client.js';
|
||||||
|
import { mapTweetResult, parseTweetsFromInstructions } from '../src/lib/twitter-client-utils.js';
|
||||||
|
import { validCookies } from './twitter-client-fixtures.js';
|
||||||
|
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapTweetResult with includeRaw', () => {
|
||||||
|
const makeTweetResult = (id: string, text: string, username = `user${id}`) => ({
|
||||||
|
rest_id: id,
|
||||||
|
legacy: {
|
||||||
|
full_text: text,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
reply_count: 1,
|
||||||
|
retweet_count: 2,
|
||||||
|
favorite_count: 3,
|
||||||
|
conversation_id_str: id,
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: `u${id}`,
|
||||||
|
legacy: { screen_name: username, name: `User ${id}` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Extra fields that should only be in _raw
|
||||||
|
entities: {
|
||||||
|
urls: [{ url: 'https://t.co/abc', expanded_url: 'https://example.com' }],
|
||||||
|
hashtags: [{ text: 'test' }],
|
||||||
|
},
|
||||||
|
extended_entities: {
|
||||||
|
media: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/test.jpg' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include _raw by default', () => {
|
||||||
|
const result = makeTweetResult('1', 'hello');
|
||||||
|
const mapped = mapTweetResult(result, { quoteDepth: 1 });
|
||||||
|
|
||||||
|
expect(mapped).toBeDefined();
|
||||||
|
expect(mapped?.id).toBe('1');
|
||||||
|
expect(mapped?.text).toBe('hello');
|
||||||
|
expect(mapped?._raw).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include _raw when includeRaw is false', () => {
|
||||||
|
const result = makeTweetResult('1', 'hello');
|
||||||
|
const mapped = mapTweetResult(result, { quoteDepth: 1, includeRaw: false });
|
||||||
|
|
||||||
|
expect(mapped).toBeDefined();
|
||||||
|
expect(mapped?._raw).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes _raw when includeRaw is true', () => {
|
||||||
|
const result = makeTweetResult('1', 'hello');
|
||||||
|
const mapped = mapTweetResult(result, { quoteDepth: 1, includeRaw: true });
|
||||||
|
|
||||||
|
expect(mapped).toBeDefined();
|
||||||
|
expect(mapped?.id).toBe('1');
|
||||||
|
expect(mapped?.text).toBe('hello');
|
||||||
|
expect(mapped?._raw).toBeDefined();
|
||||||
|
expect(mapped?._raw).toBe(result);
|
||||||
|
// Verify the raw data contains fields not in the curated output
|
||||||
|
expect(mapped?._raw?.entities).toBeDefined();
|
||||||
|
expect(mapped?._raw?.extended_entities).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes _raw in quoted tweets when includeRaw is true', () => {
|
||||||
|
const quoted = makeTweetResult('2', 'quoted tweet');
|
||||||
|
const root = makeTweetResult('1', 'root tweet');
|
||||||
|
(root as Record<string, unknown>).quoted_status_result = { result: quoted };
|
||||||
|
|
||||||
|
const mapped = mapTweetResult(root, { quoteDepth: 1, includeRaw: true });
|
||||||
|
|
||||||
|
expect(mapped?._raw).toBeDefined();
|
||||||
|
expect(mapped?.quotedTweet?._raw).toBeDefined();
|
||||||
|
expect(mapped?.quotedTweet?._raw).toBe(quoted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include _raw in quoted tweets when includeRaw is false', () => {
|
||||||
|
const quoted = makeTweetResult('2', 'quoted tweet');
|
||||||
|
const root = makeTweetResult('1', 'root tweet');
|
||||||
|
(root as Record<string, unknown>).quoted_status_result = { result: quoted };
|
||||||
|
|
||||||
|
const mapped = mapTweetResult(root, { quoteDepth: 1, includeRaw: false });
|
||||||
|
|
||||||
|
expect(mapped?._raw).toBeUndefined();
|
||||||
|
expect(mapped?.quotedTweet?._raw).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with legacy number-only quoteDepth parameter', () => {
|
||||||
|
const result = makeTweetResult('1', 'hello');
|
||||||
|
// This tests backward compatibility
|
||||||
|
const mapped = mapTweetResult(result, 1);
|
||||||
|
|
||||||
|
expect(mapped).toBeDefined();
|
||||||
|
expect(mapped?.id).toBe('1');
|
||||||
|
expect(mapped?._raw).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseTweetsFromInstructions with includeRaw', () => {
|
||||||
|
const makeInstruction = (tweets: Array<{ id: string; text: string }>) => [
|
||||||
|
{
|
||||||
|
entries: tweets.map((t) => ({
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
tweet_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: t.id,
|
||||||
|
legacy: {
|
||||||
|
full_text: t.text,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
legacy: { screen_name: `user${t.id}`, name: `User ${t.id}` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('does not include _raw by default', () => {
|
||||||
|
const instructions = makeInstruction([{ id: '1', text: 'tweet 1' }]);
|
||||||
|
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: 1 });
|
||||||
|
|
||||||
|
expect(tweets).toHaveLength(1);
|
||||||
|
expect(tweets[0]._raw).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes _raw when includeRaw is true', () => {
|
||||||
|
const instructions = makeInstruction([
|
||||||
|
{ id: '1', text: 'tweet 1' },
|
||||||
|
{ id: '2', text: 'tweet 2' },
|
||||||
|
]);
|
||||||
|
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: 1, includeRaw: true });
|
||||||
|
|
||||||
|
expect(tweets).toHaveLength(2);
|
||||||
|
expect(tweets[0]._raw).toBeDefined();
|
||||||
|
expect(tweets[0]._raw?.rest_id).toBe('1');
|
||||||
|
expect(tweets[1]._raw).toBeDefined();
|
||||||
|
expect(tweets[1]._raw?.rest_id).toBe('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with legacy number-only quoteDepth parameter', () => {
|
||||||
|
const instructions = makeInstruction([{ id: '1', text: 'tweet 1' }]);
|
||||||
|
// This tests backward compatibility
|
||||||
|
const tweets = parseTweetsFromInstructions(instructions, 1);
|
||||||
|
|
||||||
|
expect(tweets).toHaveLength(1);
|
||||||
|
expect(tweets[0]._raw).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwitterClient getTweet with includeRaw option', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockTweetResponse = {
|
||||||
|
data: {
|
||||||
|
tweetResult: {
|
||||||
|
result: {
|
||||||
|
rest_id: '12345',
|
||||||
|
legacy: {
|
||||||
|
full_text: 'Test tweet',
|
||||||
|
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
|
||||||
|
reply_count: 1,
|
||||||
|
retweet_count: 2,
|
||||||
|
favorite_count: 3,
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: 'u123',
|
||||||
|
legacy: { screen_name: 'testuser', name: 'Test User' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entities: {
|
||||||
|
urls: [{ url: 'https://t.co/abc', expanded_url: 'https://example.com' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('does not include _raw by default', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockTweetResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getTweet('12345');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweet?._raw).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes _raw when includeRaw option is true', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockTweetResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getTweet('12345', { includeRaw: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweet?._raw).toBeDefined();
|
||||||
|
expect(result.tweet?._raw?.rest_id).toBe('12345');
|
||||||
|
expect(result.tweet?._raw?.entities).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwitterClient getReplies with includeRaw option', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRepliesResponse = {
|
||||||
|
data: {
|
||||||
|
threaded_conversation_with_injections_v2: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
tweet_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: '99999',
|
||||||
|
legacy: {
|
||||||
|
full_text: 'A reply',
|
||||||
|
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
|
||||||
|
in_reply_to_status_id_str: '12345',
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
legacy: { screen_name: 'replier', name: 'Replier' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('includes _raw in replies when includeRaw is true', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockRepliesResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getReplies('12345', { includeRaw: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweets).toHaveLength(1);
|
||||||
|
expect(result.tweets?.[0]._raw).toBeDefined();
|
||||||
|
expect(result.tweets?.[0]._raw?.rest_id).toBe('99999');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwitterClient getThread with includeRaw option', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockThreadResponse = {
|
||||||
|
data: {
|
||||||
|
threaded_conversation_with_injections_v2: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
tweet_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: '12345',
|
||||||
|
legacy: {
|
||||||
|
full_text: 'Thread start',
|
||||||
|
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
|
||||||
|
conversation_id_str: '12345',
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
legacy: { screen_name: 'author', name: 'Author' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('includes _raw in thread tweets when includeRaw is true', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockThreadResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getThread('12345', { includeRaw: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweets).toHaveLength(1);
|
||||||
|
expect(result.tweets?.[0]._raw).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwitterClient search with includeRaw option', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSearchResponse = {
|
||||||
|
data: {
|
||||||
|
search_by_raw_query: {
|
||||||
|
search_timeline: {
|
||||||
|
timeline: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
tweet_results: {
|
||||||
|
result: {
|
||||||
|
rest_id: '11111',
|
||||||
|
legacy: {
|
||||||
|
full_text: 'Search result',
|
||||||
|
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
legacy: { screen_name: 'found', name: 'Found User' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('includes _raw in search results when includeRaw is true', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockSearchResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.search('test query', 10, { includeRaw: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweets).toHaveLength(1);
|
||||||
|
expect(result.tweets?.[0]._raw).toBeDefined();
|
||||||
|
expect(result.tweets?.[0]._raw?.rest_id).toBe('11111');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include _raw in search results by default', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockSearchResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.search('test query', 10);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweets?.[0]._raw).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('--json-full flag behavior (without --json)', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockTweetResponse = {
|
||||||
|
data: {
|
||||||
|
tweetResult: {
|
||||||
|
result: {
|
||||||
|
rest_id: '12345',
|
||||||
|
legacy: {
|
||||||
|
full_text: 'Test tweet',
|
||||||
|
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
legacy: { screen_name: 'testuser', name: 'Test User' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('includeRaw works independently without requiring other json options', async () => {
|
||||||
|
// This tests that --json-full alone (without --json) will include _raw
|
||||||
|
// The CLI handles output format separately, but the client should work with just includeRaw
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => mockTweetResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
// Only passing includeRaw: true, simulating --json-full without --json
|
||||||
|
const result = await client.getTweet('12345', { includeRaw: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.tweet).toBeDefined();
|
||||||
|
expect(result.tweet?._raw).toBeDefined();
|
||||||
|
// Verify the result is JSON-serializable (would be used for JSON output)
|
||||||
|
const serialized = JSON.stringify(result.tweet);
|
||||||
|
expect(serialized).toContain('"_raw"');
|
||||||
|
expect(serialized).toContain('"id":"12345"');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user