diff --git a/src/index.ts b/src/index.ts index 47c499b..372d60d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { Command } from 'commander'; import { resolveCredentials } from './lib/cookies.js'; +import { extractTweetId } from './lib/extract-tweet-id.js'; import { TwitterClient } from './lib/twitter-client.js'; const program = new Command(); @@ -23,19 +24,6 @@ program .option('--ct0 ', 'Twitter ct0 cookie') .option('--chrome-profile ', 'Chrome profile name for cookie extraction'); -/** - * Extract tweet ID from URL or return as-is if already an ID - */ -function extractTweetId(input: string): string { - // If it's a URL, extract the tweet ID - const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); - if (urlMatch) { - return urlMatch[1]; - } - // Assume it's already an ID - return input; -} - // Tweet command program .command('tweet') diff --git a/src/lib/extract-tweet-id.ts b/src/lib/extract-tweet-id.ts new file mode 100644 index 0000000..6b90954 --- /dev/null +++ b/src/lib/extract-tweet-id.ts @@ -0,0 +1,12 @@ +/** + * Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID. + */ +export function extractTweetId(input: string): string { + // If it's a URL, extract the tweet ID + const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); + if (urlMatch) { + return urlMatch[1]; + } + // Assume it's already an ID + return input; +} diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 3566e48..2b80975 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -77,6 +77,55 @@ export class TwitterClient { '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'; } + private findTweetInInstructions( + instructions: + | Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: { + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + }; + core?: { + user_results?: { + result?: { + legacy?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + }; + }; + }; + }; + }>; + }> + | undefined, + tweetId: string, + ) { + if (!instructions) return undefined; + + for (const instruction of instructions) { + for (const entry of instruction.entries || []) { + const result = entry.content?.itemContent?.tweet_results?.result; + if (result?.rest_id === tweetId) { + return result; + } + } + } + + return undefined; + } + private getHeaders(): Record { return { authorization: @@ -222,43 +271,13 @@ export class TwitterClient { }; } - // Try to find the tweet in the conversation thread - const instructions = data.data?.threaded_conversation_with_injections_v2?.instructions; - let tweetResult: - | { - rest_id?: string; - legacy?: { - full_text?: string; - created_at?: string; - reply_count?: number; - retweet_count?: number; - favorite_count?: number; - }; - core?: { - user_results?: { - result?: { - legacy?: { - screen_name?: string; - name?: string; - }; - }; - }; - }; - } - | undefined; - - if (instructions) { - for (const instruction of instructions) { - for (const entry of instruction.entries || []) { - const result = entry.content?.itemContent?.tweet_results?.result; - if (result?.rest_id === tweetId) { - tweetResult = result; - break; - } - } - if (tweetResult) break; - } - } + // Prefer direct tweetResult if present, otherwise search the conversation thread + let tweetResult = + data.data?.tweetResult?.result ?? + this.findTweetInInstructions( + data.data?.threaded_conversation_with_injections_v2?.instructions, + tweetId, + ); if (!tweetResult) { return { diff --git a/tests/cli.test.ts b/tests/cli.test.ts index d855687..5e6d6f5 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,16 +1,8 @@ import { describe, expect, it } from 'vitest'; +import { extractTweetId } from '../src/lib/extract-tweet-id.js'; describe('CLI utilities', () => { describe('extractTweetId', () => { - // Testing the URL extraction logic inline since it's a simple function - function extractTweetId(input: string): string { - const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); - if (urlMatch) { - return urlMatch[1]; - } - return input; - } - it('should extract ID from x.com URL', () => { const url = 'https://x.com/steipete/status/1234567890123456789'; expect(extractTweetId(url)).toBe('1234567890123456789'); diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 4cf1869..c39a1f1 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -2,12 +2,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TwitterClient } from '../src/lib/twitter-client.js'; describe('TwitterClient', () => { + const originalFetch = global.fetch; const validCookies = { authToken: 'test_auth_token', ct0: 'test_ct0_token', source: 'test', }; + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + describe('constructor', () => { it('should throw if authToken is missing', () => { expect( @@ -41,10 +47,6 @@ describe('TwitterClient', () => { global.fetch = mockFetch; }); - afterEach(() => { - vi.restoreAllMocks(); - }); - it('should post a tweet successfully', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -127,10 +129,6 @@ describe('TwitterClient', () => { global.fetch = mockFetch; }); - afterEach(() => { - vi.restoreAllMocks(); - }); - it('should post a reply with correct reply_to_tweet_id', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -159,4 +157,53 @@ describe('TwitterClient', () => { expect(body.variables.tweet_text).toBe('This is a reply'); }); }); + + describe('getTweet', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + it('should return tweet data from root tweetResult', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + tweetResult: { + result: { + rest_id: '12345', + legacy: { + full_text: 'Root tweet text', + created_at: 'Mon Jan 01 00:00:00 +0000 2024', + reply_count: 1, + retweet_count: 2, + favorite_count: 3, + }, + core: { + user_results: { + result: { + legacy: { + screen_name: 'user', + name: 'User Name', + }, + }, + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getTweet('12345'); + + expect(result.success).toBe(true); + expect(result.tweet?.id).toBe('12345'); + expect(result.tweet?.text).toBe('Root tweet text'); + expect(result.tweet?.author.username).toBe('user'); + }); + }); });