diff --git a/CHANGELOG.md b/CHANGELOG.md index e96e0b1..07adda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - `likes` command to list your liked tweets (thanks @swairshah). +- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml). ### Changed - Query ID updater now tracks the Likes GraphQL operation. diff --git a/README.md b/README.md index d63c9f2..a61ad9c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ bird query-ids --fresh Global options: - `--timeout `: abort requests after the given timeout (milliseconds). +- `--quote-depth `: max quoted tweet depth in JSON output (default: 1; 0 disables). - `--plain`: stable output (no emoji, no color). - `--no-emoji`: disable emoji output. - `--no-color`: disable ANSI colors (or set `NO_COLOR=1`). @@ -117,12 +118,14 @@ Example `~/.config/bird/config.json5`: // Cookie source order for browser extraction (string or array) cookieSource: ["firefox", "safari"], firefoxProfile: "default-release", - timeoutMs: 20000 + timeoutMs: 20000, + quoteDepth: 1 } ``` Environment shortcuts: - `BIRD_TIMEOUT_MS` +- `BIRD_QUOTE_DEPTH` ## Output @@ -130,6 +133,24 @@ Environment shortcuts: - `read` returns full text for Notes and Articles when present. - Use `--plain` for stable, script-friendly output (no emoji, no color). +### JSON Schema + +When using `--json`, tweet objects include: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Tweet ID | +| `text` | string | Full tweet text (includes Note/Article content when present) | +| `author` | object | `{ username, name }` | +| `authorId` | string? | Author's user ID | +| `createdAt` | string | Timestamp | +| `replyCount` | number | Number of replies | +| `retweetCount` | number | Number of retweets | +| `likeCount` | number | Number of likes | +| `conversationId` | string | Thread conversation ID | +| `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) | +| `quotedTweet` | object? | Embedded quote tweet (same schema; depth controlled by `--quote-depth`) | + ## Query IDs (GraphQL) X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as: diff --git a/src/index.ts b/src/index.ts index e83d2e9..099f7bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,6 +109,7 @@ type BirdConfig = { firefoxProfile?: string; cookieSource?: CookieSource | CookieSource[]; timeoutMs?: number; + quoteDepth?: number; }; function readConfigFile(path: string): Partial { @@ -193,6 +194,7 @@ program .option('--media ', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) .option('--alt ', 'Alt text for the corresponding --media (repeatable)', collect, []) .option('--timeout ', 'Request timeout in milliseconds') + .option('--quote-depth ', 'Max quoted tweet depth (default: 1; 0 disables)') .option('--plain', 'Plain output (stable, no emoji, no color)') .option('--no-emoji', 'Disable emoji output') .option('--no-color', 'Disable ANSI colors (or set NO_COLOR)'); @@ -241,6 +243,23 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS); } +function resolveQuoteDepth(...values: Array): number | undefined { + for (const value of values) { + if (value === undefined || value === null || value === '') { + continue; + } + const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed >= 0) { + return Math.floor(parsed); + } + } + return undefined; +} + +function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined { + return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, process.env.BIRD_QUOTE_DEPTH); +} + function detectMime(path: string): string | null { const ext = path.toLowerCase(); if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) { @@ -402,6 +421,7 @@ program .action(async (text: string) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); let media: MediaSpec[] = []; try { media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); @@ -425,7 +445,7 @@ program console.error(`${l('source')}${cookies.source}`); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); let mediaIds: string[] | undefined; if (media.length > 0) { const uploaded: string[] = []; @@ -460,6 +480,7 @@ program .action(async (tweetIdOrUrl: string, text: string) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); let media: MediaSpec[] = []; try { media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); @@ -486,7 +507,7 @@ program console.error(`${p('info')}Replying to tweet: ${tweetId}`); - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); let mediaIds: string[] | undefined; if (media.length > 0) { const uploaded: string[] = []; @@ -521,6 +542,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); @@ -535,7 +557,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getTweet(tweetId); if (result.success && result.tweet) { @@ -564,6 +586,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -577,7 +600,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getReplies(tweetId); if (result.success && result.tweets) { @@ -597,6 +620,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -610,7 +634,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getThread(tweetId); if (result.success && result.tweets) { @@ -631,6 +655,7 @@ program .action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -644,7 +669,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.search(query, count); if (result.success && result.tweets) { @@ -665,6 +690,7 @@ program .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user); @@ -686,7 +712,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); if (!query) { const who = await client.getCurrentUser(); @@ -762,6 +788,7 @@ program .action(async (cmdOpts: { count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '20', 10); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -775,7 +802,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getLikes(count); if (result.success && result.tweets) { @@ -793,6 +820,7 @@ program .action(async () => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -809,7 +837,7 @@ program console.error(`${l('source')}${cookies.source}`); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getCurrentUser(); const credentialSource = cookies.source ?? 'env/auto-detected cookies'; diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 08a068c..c84eeaa 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -41,7 +41,18 @@ const QUERY_IDS: Record = { const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array; +function normalizeQuoteDepth(value?: number): number { + if (value === undefined || value === null) { + return 1; + } + if (!Number.isFinite(value)) { + return 1; + } + return Math.max(0, Math.floor(value)); +} + type GraphqlTweetResult = { + __typename?: string; rest_id?: string; legacy?: { full_text?: string; @@ -186,6 +197,10 @@ type GraphqlTweetResult = { }>; }>; }; + tweet?: GraphqlTweetResult; + quoted_status_result?: { + result?: GraphqlTweetResult; + }; }; export type TweetResult = @@ -218,6 +233,8 @@ export interface TweetData { likeCount?: number; conversationId?: string; inReplyToStatusId?: string; + // Optional quoted tweet; depth controlled by quoteDepth (default: 1). + quotedTweet?: TweetData; } export interface GetTweetResult { @@ -246,6 +263,8 @@ export interface TwitterClientOptions { cookies: TwitterCookies; userAgent?: string; timeoutMs?: number; + // Max depth for quoted tweets (0 disables). Defaults to 1. + quoteDepth?: number; } interface CreateTweetResponse { @@ -270,6 +289,7 @@ export class TwitterClient { private cookieHeader: string; private userAgent: string; private timeoutMs?: number; + private quoteDepth: number; private clientUuid: string; private clientDeviceId: string; private clientUserId?: string; @@ -285,6 +305,7 @@ export class TwitterClient { options.userAgent || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; this.timeoutMs = options.timeoutMs; + this.quoteDepth = normalizeQuoteDepth(options.quoteDepth); this.clientUuid = randomUUID(); this.clientDeviceId = randomUUID(); } @@ -711,7 +732,17 @@ export class TwitterClient { ); } - private mapTweetResult(result: GraphqlTweetResult | undefined): TweetData | undefined { + private unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined { + if (!result) { + return undefined; + } + if (result.tweet) { + return result.tweet; + } + return result; + } + + private mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth = this.quoteDepth): TweetData | undefined { const userResult = result?.core?.user_results?.result; const userLegacy = userResult?.legacy; const userCore = userResult?.core; @@ -727,6 +758,14 @@ export class TwitterClient { return undefined; } + let quotedTweet: TweetData | undefined; + if (quoteDepth > 0) { + const quotedResult = this.unwrapTweetResult(result.quoted_status_result?.result); + if (quotedResult) { + quotedTweet = this.mapTweetResult(quotedResult, quoteDepth - 1); + } + } + return { id: result.rest_id, text, @@ -741,6 +780,7 @@ export class TwitterClient { name: name || username, }, authorId: userId, + quotedTweet, }; } diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index cef3c3c..15e6128 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { TwitterClient } from '../src/lib/twitter-client.js'; +import { type TweetData, TwitterClient } from '../src/lib/twitter-client.js'; describe('TwitterClient', () => { const originalFetch = global.fetch; @@ -960,6 +960,95 @@ describe('TwitterClient', () => { }); }); + describe('quoted tweets', () => { + const makeTweetResult = ( + id: string, + text: string, + username = `user${id}`, + name = `User ${id}`, + ): Record => ({ + rest_id: id, + legacy: { + full_text: text, + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: id, + }, + core: { + user_results: { + result: { + rest_id: `u${id}`, + legacy: { screen_name: username, name }, + }, + }, + }, + }); + + it('includes one level of quoted tweet by default', () => { + const quoted = makeTweetResult('2', 'quoted'); + const root = makeTweetResult('1', 'root'); + root.quoted_status_result = { result: quoted }; + + const client = new TwitterClient({ cookies: validCookies }); + const mapped = ( + client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } + ).mapTweetResult(root); + + expect(mapped?.quotedTweet?.id).toBe('2'); + expect(mapped?.quotedTweet?.quotedTweet).toBeUndefined(); + }); + + it('honors quoteDepth = 0', () => { + const quoted = makeTweetResult('2', 'quoted'); + const root = makeTweetResult('1', 'root'); + root.quoted_status_result = { result: quoted }; + + const client = new TwitterClient({ cookies: validCookies, quoteDepth: 0 }); + const mapped = ( + client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } + ).mapTweetResult(root); + + expect(mapped?.quotedTweet).toBeUndefined(); + }); + + it('recurses when quoteDepth > 1', () => { + const quoted2 = makeTweetResult('3', 'quoted2'); + const quoted1 = makeTweetResult('2', 'quoted1'); + quoted1.quoted_status_result = { result: quoted2 }; + const root = makeTweetResult('1', 'root'); + root.quoted_status_result = { result: quoted1 }; + + const client = new TwitterClient({ cookies: validCookies, quoteDepth: 2 }); + const mapped = ( + client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } + ).mapTweetResult(root); + + expect(mapped?.quotedTweet?.id).toBe('2'); + expect(mapped?.quotedTweet?.quotedTweet?.id).toBe('3'); + expect(mapped?.quotedTweet?.quotedTweet?.quotedTweet).toBeUndefined(); + }); + + it('unwraps quoted tweet visibility wrappers', () => { + const quoted = makeTweetResult('2', 'quoted'); + const root = makeTweetResult('1', 'root'); + root.quoted_status_result = { + result: { + __typename: 'TweetWithVisibilityResults', + tweet: quoted, + }, + }; + + const client = new TwitterClient({ cookies: validCookies }); + const mapped = ( + client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } + ).mapTweetResult(root); + + expect(mapped?.quotedTweet?.id).toBe('2'); + }); + }); + describe('search', () => { let mockFetch: ReturnType;