diff --git a/package.json b/package.json index 7cfc0439..4ae70f0a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scraper", "crawler" ], - "version": "0.20.4", + "version": "0.21.0", "main": "dist/default/cjs/index.js", "types": "./dist/types/index.d.ts", "exports": { diff --git a/rollup.config.mjs b/rollup.config.mjs index c6c967e1..6c9fd3ab 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -8,7 +8,6 @@ export default [ esbuild({ define: { PLATFORM_NODE: 'false', - PLATFORM_NODE_JEST: 'false', }, }), ], @@ -31,7 +30,6 @@ export default [ esbuild({ define: { PLATFORM_NODE: 'true', - PLATFORM_NODE_JEST: 'false', }, }), ], @@ -66,7 +64,6 @@ export default [ esbuild({ define: { PLATFORM_NODE: 'true', - PLATFORM_NODE_JEST: 'false', }, }), ], diff --git a/src/api-data.ts b/src/api-data.ts index f73c1f7a..ab316e12 100644 --- a/src/api-data.ts +++ b/src/api-data.ts @@ -6,7 +6,6 @@ import stringify from 'json-stable-stringify'; * not contain any information that you do not want published to NPM. */ const endpoints = { - // TODO: Migrate other endpoint URLs here UserTweets: 'https://x.com/i/api/graphql/oRJs8SLCRNRbQzuZG93_oA/UserTweets?variables=%7B%22userId%22%3A%221806359170830172162%22%2C%22count%22%3A20%2C%22includePromotedContent%22%3Atrue%2C%22withQuickPromoteEligibilityTweetFields%22%3Atrue%2C%22withVoice%22%3Atrue%7D&features=%7B%22rweb_video_screen_enabled%22%3Afalse%2C%22payments_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22responsive_web_profile_redirect_enabled%22%3Afalse%2C%22rweb_tipjar_consumption_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Atrue%2C%22responsive_web_jetfuel_frame%22%3Atrue%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Atrue%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22responsive_web_grok_show_grok_translated_post%22%3Atrue%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22responsive_web_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_imagine_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_community_note_auto_translation_is_enabled%22%3Afalse%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D&fieldToggles=%7B%22withArticlePlainText%22%3Afalse%7D', UserTweetsAndReplies: @@ -21,6 +20,12 @@ const endpoints = { 'https://api.x.com/graphql/tCVRZ3WCvoj0BVO7BKnL-Q/TweetResultByRestId?variables=%7B%22tweetId%22%3A%221985465713096794294%22%2C%22withCommunity%22%3Afalse%2C%22includePromotedContent%22%3Afalse%2C%22withVoice%22%3Afalse%7D&features=%7B%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Afalse%2C%22responsive_web_jetfuel_frame%22%3Atrue%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Atrue%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22responsive_web_grok_show_grok_translated_post%22%3Afalse%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22payments_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22responsive_web_profile_redirect_enabled%22%3Afalse%2C%22rweb_tipjar_consumption_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22responsive_web_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_imagine_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_community_note_auto_translation_is_enabled%22%3Afalse%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D&fieldToggles=%7B%22withArticleRichContentState%22%3Atrue%2C%22withArticlePlainText%22%3Afalse%2C%22withGrokAnalyze%22%3Afalse%2C%22withDisallowedReplyControls%22%3Afalse%7D', ListTweets: 'https://x.com/i/api/graphql/S1Sm3_mNJwa-fnY9htcaAQ/ListLatestTweetsTimeline?variables=%7B%22listId%22%3A%221736495155002106192%22%2C%22count%22%3A20%7D&features=%7B%22rweb_video_screen_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22rweb_tipjar_consumption_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Atrue%2C%22responsive_web_jetfuel_frame%22%3Afalse%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Atrue%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22responsive_web_grok_show_grok_translated_post%22%3Afalse%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22responsive_web_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D', + SearchTimeline: + 'https://x.com/i/api/graphql/bshMIjqDk8LTXTq4w91WKw/SearchTimeline?variables=%7B%22rawQuery%22%3A%22twitter%22%2C%22count%22%3A20%2C%22querySource%22%3A%22typed_query%22%2C%22product%22%3A%22Top%22%2C%22withGrokTranslatedBio%22%3Afalse%7D&features=%7B%22rweb_video_screen_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22responsive_web_profile_redirect_enabled%22%3Afalse%2C%22rweb_tipjar_consumption_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Atrue%2C%22responsive_web_jetfuel_frame%22%3Atrue%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Atrue%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22responsive_web_grok_show_grok_translated_post%22%3Atrue%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22responsive_web_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_imagine_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_community_note_auto_translation_is_enabled%22%3Afalse%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D', + Followers: + 'https://x.com/i/api/graphql/SCu9fVIlCUm-BM8-tL5pkQ/Followers?variables=%7B%22userId%22%3A%221806359170830172162%22%2C%22count%22%3A20%2C%22includePromotedContent%22%3Afalse%2C%22withGrokTranslatedBio%22%3Afalse%7D&features=%7B%22rweb_video_screen_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22responsive_web_profile_redirect_enabled%22%3Afalse%2C%22rweb_tipjar_consumption_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Atrue%2C%22responsive_web_jetfuel_frame%22%3Atrue%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Atrue%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22responsive_web_grok_show_grok_translated_post%22%3Atrue%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22responsive_web_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_imagine_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_community_note_auto_translation_is_enabled%22%3Afalse%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D', + Following: + 'https://x.com/i/api/graphql/S5xUN9s2v4xk50KWGGvyvQ/Following?variables=%7B%22userId%22%3A%221806359170830172162%22%2C%22count%22%3A20%2C%22includePromotedContent%22%3Afalse%2C%22withGrokTranslatedBio%22%3Afalse%7D&features=%7B%22rweb_video_screen_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22responsive_web_profile_redirect_enabled%22%3Afalse%2C%22rweb_tipjar_consumption_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Atrue%2C%22responsive_web_jetfuel_frame%22%3Atrue%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Atrue%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22responsive_web_grok_show_grok_translated_post%22%3Atrue%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22responsive_web_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_imagine_annotation_enabled%22%3Atrue%2C%22responsive_web_grok_community_note_auto_translation_is_enabled%22%3Afalse%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D', } as const; export interface EndpointFieldInfo { diff --git a/src/auth-user.test.ts b/src/auth-user.test.ts index aba5d5f5..7aa7eb86 100644 --- a/src/auth-user.test.ts +++ b/src/auth-user.test.ts @@ -1,8 +1,9 @@ import { TwitterUserAuth } from './auth-user'; import { bearerToken } from './api'; +import { jest } from '@jest/globals'; describe('TwitterUserAuth', () => { - const mockFetch = jest.fn(); + const mockFetch = jest.fn(); let auth: TwitterUserAuth; // Common login flows @@ -32,60 +33,68 @@ describe('TwitterUserAuth', () => { '', ), headers: new Headers(), - }, + } as Response, guestToken: { ok: true, json: () => Promise.resolve({ guest_token: 'test-guest-token' }), text: () => Promise.resolve(JSON.stringify({ guest_token: 'test-guest-token' })), headers: new Headers(), - }, - success: (token: string) => ({ - ok: true, - json: () => Promise.resolve({ flow_token: token }), - text: () => Promise.resolve(JSON.stringify({ flow_token: token })), - headers: new Headers(), - }), - subtask: (token: string, subtaskId: string) => ({ - ok: true, - json: () => - Promise.resolve({ - flow_token: token, - subtasks: [{ subtask_id: subtaskId }], - }), - text: () => - Promise.resolve( - JSON.stringify({ + } as Response, + success: (token: string): Response => + ({ + ok: true, + json: () => Promise.resolve({ flow_token: token }), + text: () => Promise.resolve(JSON.stringify({ flow_token: token })), + headers: new Headers(), + } as Response), + subtask: (token: string, subtaskId: string): Response => + ({ + ok: true, + json: () => + Promise.resolve({ flow_token: token, subtasks: [{ subtask_id: subtaskId }], }), - ), - headers: new Headers(), - }), - error: (code: number, message: string) => ({ - ok: true, - json: () => - Promise.resolve({ - flow_token: 'error-token', - errors: [{ code, message }], - }), - text: () => - Promise.resolve( - JSON.stringify({ + text: () => + Promise.resolve( + JSON.stringify({ + flow_token: token, + subtasks: [{ subtask_id: subtaskId }], + }), + ), + headers: new Headers(), + } as Response), + error: (code: number, message: string): Response => + ({ + ok: true, + json: () => + Promise.resolve({ flow_token: 'error-token', errors: [{ code, message }], }), - ), - headers: new Headers(), - }), - httpError: (status: number, statusText: string, message: string) => ({ - ok: false, - status, - statusText, - headers: new Headers(), - text: () => Promise.resolve(message), - json: () => Promise.resolve({ errors: [{ code: status, message }] }), - }), + text: () => + Promise.resolve( + JSON.stringify({ + flow_token: 'error-token', + errors: [{ code, message }], + }), + ), + headers: new Headers(), + } as Response), + httpError: ( + status: number, + statusText: string, + message: string, + ): Response => + ({ + ok: false, + status, + statusText, + headers: new Headers(), + text: () => Promise.resolve(message), + json: () => Promise.resolve({ errors: [{ code: status, message }] }), + } as Response), }; // Test utilities @@ -103,30 +112,29 @@ describe('TwitterUserAuth', () => { }; const mockLoginFlow = (subtasks: string[]) => { - mockFetch - .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) // For transaction ID generation - .mockResolvedValueOnce(mockResponses.subtask('token1', subtasks[0])); + // Guest token fetch + mockFetch.mockResolvedValueOnce(mockResponses.guestToken); + + // initLogin: task endpoint returns first subtask + mockFetch.mockResolvedValueOnce( + mockResponses.subtask('token1', subtasks[0]), + ); + // Each subsequent subtask handler: task endpoint for (let i = 1; i < subtasks.length; i++) { - mockFetch - .mockResolvedValueOnce(mockResponses.xcomHomepage) // For each transaction ID generation - .mockResolvedValueOnce( - mockResponses.subtask(`token${i + 1}`, subtasks[i]), - ); + mockFetch.mockResolvedValueOnce( + mockResponses.subtask(`token${i + 1}`, subtasks[i]), + ); } - mockFetch.mockResolvedValueOnce(mockResponses.success('final')); }; const setupAuthenticatedState = async () => { + // Use a minimal login flow that goes straight to success mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) .mockResolvedValueOnce( mockResponses.subtask('token1', 'LoginSuccessSubtask'), - ) - .mockResolvedValueOnce(mockResponses.xcomHomepage) - .mockResolvedValueOnce(mockResponses.success('final')); + ); await auth.login('testuser', 'testpass'); mockFetch.mockClear(); @@ -141,13 +149,12 @@ describe('TwitterUserAuth', () => { mockLoginFlow(loginFlows.standard); await auth.login('testuser', 'testpass'); - // Guest token + (x.com + subtask) * 4 standard flows + final = 10 total - expect(mockFetch).toHaveBeenCalledTimes(10); + // Guest token + 4 subtask calls = 5 total + expect(mockFetch).toHaveBeenCalledTimes(5); expect(mockFetch.mock.calls[0][0]).toBe( 'https://api.x.com/1.1/guest/activate.json', ); - expect(mockFetch.mock.calls[1][0]).toBe('https://x.com'); - expect(mockFetch.mock.calls[2][0]).toBe( + expect(mockFetch.mock.calls[1][0]).toBe( 'https://api.x.com/1.1/onboarding/task.json?flow_name=login', ); }); @@ -155,7 +162,6 @@ describe('TwitterUserAuth', () => { it('should handle login failure', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) .mockResolvedValueOnce(mockResponses.error(99, 'Invalid credentials')); await expect(auth.login('testuser', 'wrongpass')).rejects.toThrow( @@ -166,7 +172,6 @@ describe('TwitterUserAuth', () => { it('should handle DenyLoginSubtask flow', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) .mockResolvedValueOnce( mockResponses.subtask('token1', 'DenyLoginSubtask'), ); @@ -179,48 +184,42 @@ describe('TwitterUserAuth', () => { it('should handle 2FA challenge', async () => { mockLoginFlow(loginFlows.twoFactor); await auth.login('testuser', 'testpass', undefined, 'JBSWY3DPEHPK3PXP'); - // Guest token + (x.com + subtask) * 5 2FA flows + final = 12 total - expect(mockFetch).toHaveBeenCalledTimes(12); + // Guest token + 5 subtask calls = 6 total + expect(mockFetch).toHaveBeenCalledTimes(6); }); it('should retry 2FA challenge after failure', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) - // First 2FA challenge + // initLogin .mockResolvedValueOnce( mockResponses.subtask('token1', 'LoginTwoFactorAuthChallenge'), ) - .mockResolvedValueOnce(mockResponses.xcomHomepage) - // First attempt fails + // First 2FA attempt fails - returns same subtask .mockResolvedValueOnce( mockResponses.subtask('token2', 'LoginTwoFactorAuthChallenge'), ) - .mockResolvedValueOnce(mockResponses.xcomHomepage) - // Second attempt succeeds + // Second 2FA attempt succeeds .mockResolvedValueOnce( mockResponses.subtask('token3', 'LoginSuccessSubtask'), - ) - .mockResolvedValueOnce(mockResponses.xcomHomepage) - // Final success - .mockResolvedValueOnce(mockResponses.success('final')); + ); await auth.login('testuser', 'testpass', undefined, 'JBSWY3DPEHPK3PXP'); - expect(mockFetch).toHaveBeenCalledTimes(9); + expect(mockFetch).toHaveBeenCalledTimes(4); }); it('should handle all 2FA attempts failing', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) + // initLogin returns 2FA challenge .mockResolvedValueOnce( mockResponses.subtask('token1', 'LoginTwoFactorAuthChallenge'), ) - .mockResolvedValueOnce(mockResponses.xcomHomepage) + // First 2FA attempt fails .mockResolvedValueOnce(mockResponses.error(236, 'Bad 2FA code')) - .mockResolvedValueOnce(mockResponses.xcomHomepage) + // Second 2FA attempt fails .mockResolvedValueOnce(mockResponses.error(236, 'Bad 2FA code')) - .mockResolvedValueOnce(mockResponses.xcomHomepage) + // Third 2FA attempt fails .mockResolvedValueOnce(mockResponses.error(236, 'Bad 2FA code')); await expect( @@ -231,7 +230,7 @@ describe('TwitterUserAuth', () => { it('should handle missing TOTP secret during 2FA challenge', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) + // initLogin returns 2FA challenge .mockResolvedValueOnce( mockResponses.subtask('token1', 'LoginTwoFactorAuthChallenge'), ); @@ -244,7 +243,7 @@ describe('TwitterUserAuth', () => { it('should handle invalid TOTP secret during 2FA challenge', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) + // initLogin returns 2FA challenge .mockResolvedValueOnce( mockResponses.subtask('token1', 'LoginTwoFactorAuthChallenge'), ); @@ -257,7 +256,6 @@ describe('TwitterUserAuth', () => { it('should handle invalid subtask type', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) .mockResolvedValueOnce( mockResponses.subtask('token1', 'UnknownSubtask'), ); @@ -270,7 +268,7 @@ describe('TwitterUserAuth', () => { it('should handle network errors', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) + // initLogin fails on task endpoint .mockRejectedValueOnce(new Error('Network error')); await expect(auth.login('testuser', 'testpass')).rejects.toThrow( @@ -281,13 +279,12 @@ describe('TwitterUserAuth', () => { it('should handle invalid response format', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}), text: () => Promise.resolve('{}'), headers: new Headers(), - }); + } as Response); await expect(auth.login('testuser', 'testpass')).rejects.toThrow(); }); @@ -295,7 +292,7 @@ describe('TwitterUserAuth', () => { it('should handle rate limit errors', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) + // initLogin gets rate limited .mockResolvedValueOnce( mockResponses.httpError(429, 'Too Many Requests', 'Rate limit hit'), ); @@ -308,7 +305,7 @@ describe('TwitterUserAuth', () => { it('should handle unauthorized errors', async () => { mockFetch .mockResolvedValueOnce(mockResponses.guestToken) - .mockResolvedValueOnce(mockResponses.xcomHomepage) + // initLogin gets 401 error .mockResolvedValueOnce( mockResponses.httpError( 401, @@ -368,38 +365,31 @@ describe('TwitterUserAuth', () => { }); describe('isLoggedIn', () => { - it('should return true when logged in', async () => { - mockFetch.mockResolvedValueOnce(mockResponses.success('verify')); + it('should return true when ct0 cookie is present', async () => { + // Set up a ct0 cookie in the jar + await auth.cookieJar().setCookie('ct0=test_token', 'https://x.com'); const result = await auth.isLoggedIn(); expect(result).toBe(true); }); - it('should return false when not logged in', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ errors: [{ code: 99 }] }), - text: () => Promise.resolve(JSON.stringify({ errors: [{ code: 99 }] })), - headers: new Headers(), - }); - + it('should return false when ct0 cookie is not present', async () => { const result = await auth.isLoggedIn(); expect(result).toBe(false); }); - it('should handle network error during status check', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - const result = await auth.isLoggedIn(); - expect(result).toBe(false); - }); + it('should return false after logout', async () => { + // Set up authenticated state with login + await setupAuthenticatedState(); - it('should handle invalid response during status check', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ errors: [{ code: -1 }] }), - text: () => Promise.resolve(JSON.stringify({ errors: [{ code: -1 }] })), - headers: new Headers(), - }); + // Manually set ct0 cookie to ensure isLoggedIn returns true + await auth.cookieJar().setCookie('ct0=test_token', 'https://x.com'); + expect(await auth.isLoggedIn()).toBe(true); + + // Logout should clear cookies + mockFetch.mockResolvedValueOnce(mockResponses.success('logout')); + await auth.logout(); + // Now should be logged out const result = await auth.isLoggedIn(); expect(result).toBe(false); }); diff --git a/src/auth-user.ts b/src/auth-user.ts index 3124b615..0da9fa99 100644 --- a/src/auth-user.ts +++ b/src/auth-user.ts @@ -240,16 +240,8 @@ export class TwitterUserAuth extends TwitterGuestAuth { } async isLoggedIn(): Promise { - const res = await requestApi( - 'https://api.x.com/1.1/account/verify_credentials.json', - this, - ); - if (!res.success) { - return false; - } - - const { value: verify } = res; - return verify && !verify.errors?.length; + const cookie = await this.getCookieString(); + return cookie.includes('ct0='); } async login( @@ -581,16 +573,13 @@ export class TwitterUserAuth extends TwitterGuestAuth { }); } - private async handleSuccessSubtask( - _subtaskId: string, - _prev: TwitterUserAuthFlowResponse, - _credentials: TwitterUserAuthCredentials, - api: FlowSubtaskHandlerApi, - ): Promise { - return await this.executeFlowTask({ - flow_token: api.getFlowToken(), - subtask_inputs: [], - }); + private async handleSuccessSubtask(): Promise { + // Login completed successfully, nothing more to do + log('Successfully logged in with user credentials.'); + return { + status: 'success', + response: {}, + }; } private async executeFlowTask( diff --git a/src/platform/index.ts b/src/platform/index.ts index 4bc4aa05..5abc5f70 100644 --- a/src/platform/index.ts +++ b/src/platform/index.ts @@ -3,7 +3,6 @@ import { PlatformExtensions, genericPlatform } from './platform-interface'; export * from './platform-interface'; declare const PLATFORM_NODE: boolean; -declare const PLATFORM_NODE_JEST: boolean; export class Platform implements PlatformExtensions { async randomizeCiphers() { @@ -15,11 +14,6 @@ export class Platform implements PlatformExtensions { if (PLATFORM_NODE) { const { platform } = await import('./node/index.js'); return platform as PlatformExtensions; - } else if (PLATFORM_NODE_JEST) { - // Jest gets unhappy when using an await import here, so we just use require instead. - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { platform } = require('./node'); - return platform as PlatformExtensions; } return genericPlatform; diff --git a/src/relationships.ts b/src/relationships.ts index 289efada..5ee07970 100644 --- a/src/relationships.ts +++ b/src/relationships.ts @@ -1,4 +1,4 @@ -import { addApiFeatures, requestApi } from './api'; +import { bearerToken2, requestApi } from './api'; import { TwitterAuth } from './auth'; import { Profile } from './profile'; import { QueryProfilesResponse } from './timeline-v1'; @@ -7,8 +7,8 @@ import { RelationshipTimeline, parseRelationshipTimeline, } from './timeline-relationship'; -import stringify from 'json-stable-stringify'; import { AuthenticationError } from './errors'; +import { apiRequestFactory } from './api-data'; export function getFollowing( userId: string, @@ -90,33 +90,22 @@ async function getFollowingTimeline( maxItems = 50; } - const variables: Record = { - userId, - count: maxItems, - includePromotedContent: false, - }; - - const features = addApiFeatures({ - responsive_web_twitter_article_tweet_consumption_enabled: false, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: - true, - longform_notetweets_inline_media_enabled: true, - responsive_web_media_download_video_enabled: false, - }); + const followingRequest = apiRequestFactory.createFollowingRequest(); + followingRequest.variables.userId = userId; + followingRequest.variables.count = maxItems; + followingRequest.variables.includePromotedContent = false; if (cursor != null && cursor != '') { - variables['cursor'] = cursor; + followingRequest.variables.cursor = cursor; } - const params = new URLSearchParams(); - const featuresStr = stringify(features); - const variablesStr = stringify(variables); - if (featuresStr) params.set('features', featuresStr); - if (variablesStr) params.set('variables', variablesStr); - const res = await requestApi( - `https://x.com/i/api/graphql/iSicc7LrzWGBgDPL0tM_TQ/Following?${params.toString()}`, + followingRequest.toRequestUrl(), auth, + 'GET', + undefined, + undefined, + bearerToken2, ); if (!res.success) { @@ -142,33 +131,22 @@ async function getFollowersTimeline( maxItems = 50; } - const variables: Record = { - userId, - count: maxItems, - includePromotedContent: false, - }; - - const features = addApiFeatures({ - responsive_web_twitter_article_tweet_consumption_enabled: false, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: - true, - longform_notetweets_inline_media_enabled: true, - responsive_web_media_download_video_enabled: false, - }); + const followersRequest = apiRequestFactory.createFollowersRequest(); + followersRequest.variables.userId = userId; + followersRequest.variables.count = maxItems; + followersRequest.variables.includePromotedContent = false; if (cursor != null && cursor != '') { - variables['cursor'] = cursor; + followersRequest.variables.cursor = cursor; } - const params = new URLSearchParams(); - const featuresStr = stringify(features); - const variablesStr = stringify(variables); - if (featuresStr) params.set('features', featuresStr); - if (variablesStr) params.set('variables', variablesStr); - const res = await requestApi( - `https://x.com/i/api/graphql/rRXFSG5vR6drKr5M37YOTw/Followers?${params.toString()}`, + followersRequest.toRequestUrl(), auth, + 'GET', + undefined, + undefined, + bearerToken2, ); if (!res.success) { diff --git a/src/search.ts b/src/search.ts index 1bbcae44..2c066709 100644 --- a/src/search.ts +++ b/src/search.ts @@ -1,4 +1,4 @@ -import { addApiFeatures, requestApi } from './api'; +import { bearerToken2, requestApi } from './api'; import { TwitterAuth } from './auth'; import { Profile } from './profile'; import { QueryProfilesResponse, QueryTweetsResponse } from './timeline-v1'; @@ -9,8 +9,8 @@ import { parseSearchTimelineTweets, parseSearchTimelineUsers, } from './timeline-search'; -import stringify from 'json-stable-stringify'; import { AuthenticationError } from './errors'; +import { apiRequestFactory } from './api-data'; /** * The categories that can be used in Twitter searches. @@ -94,61 +94,40 @@ async function getSearchTimeline( maxItems = 50; } - const variables: Record = { - rawQuery: query, - count: maxItems, - querySource: 'typed_query', - product: 'Top', - }; - - const features = addApiFeatures({ - longform_notetweets_inline_media_enabled: true, - responsive_web_enhance_cards_enabled: false, - responsive_web_media_download_video_enabled: false, - responsive_web_twitter_article_tweet_consumption_enabled: false, - tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: - true, - interactive_text_enabled: false, - responsive_web_text_conversations_enabled: false, - vibe_api_enabled: false, - }); - - const fieldToggles: Record = { - withArticleRichContentState: false, - }; + const searchTimelineRequest = apiRequestFactory.createSearchTimelineRequest(); + searchTimelineRequest.variables.rawQuery = query; + searchTimelineRequest.variables.count = maxItems; + searchTimelineRequest.variables.querySource = 'typed_query'; + searchTimelineRequest.variables.product = 'Top'; if (cursor != null && cursor != '') { - variables['cursor'] = cursor; + searchTimelineRequest.variables['cursor'] = cursor; } switch (searchMode) { case SearchMode.Latest: - variables.product = 'Latest'; + searchTimelineRequest.variables.product = 'Latest'; break; case SearchMode.Photos: - variables.product = 'Photos'; + searchTimelineRequest.variables.product = 'Photos'; break; case SearchMode.Videos: - variables.product = 'Videos'; + searchTimelineRequest.variables.product = 'Videos'; break; case SearchMode.Users: - variables.product = 'People'; + searchTimelineRequest.variables.product = 'People'; break; default: break; } - const params = new URLSearchParams(); - const featuresStr = stringify(features); - const fieldTogglesStr = stringify(fieldToggles); - const variablesStr = stringify(variables); - if (featuresStr) params.set('features', featuresStr); - if (fieldTogglesStr) params.set('fieldToggles', fieldTogglesStr); - if (variablesStr) params.set('variables', variablesStr); - const res = await requestApi( - `https://api.x.com/graphql/gkjsKepM6gl_HmFWoWKfgg/SearchTimeline?${params.toString()}`, + searchTimelineRequest.toRequestUrl(), auth, + 'GET', + undefined, + undefined, + bearerToken2, ); if (!res.success) { diff --git a/src/tweets.ts b/src/tweets.ts index 6fb78b9b..aee1f338 100644 --- a/src/tweets.ts +++ b/src/tweets.ts @@ -158,9 +158,14 @@ export async function fetchTweetsAndReplies( userTweetsRequest.variables['cursor'] = cursor; } + // Use bearerToken2 for UserTweetsAndReplies endpoint const res = await requestApi( userTweetsRequest.toRequestUrl(), auth, + 'GET', + undefined, + undefined, + bearerToken2, ); if (!res.success) { @@ -188,9 +193,14 @@ export async function fetchListTweets( listTweetsRequest.variables['cursor'] = cursor; } + // Use bearerToken2 for ListTweet endpoint const res = await requestApi( listTweetsRequest.toRequestUrl(), auth, + 'GET', + undefined, + undefined, + bearerToken2, ); if (!res.success) { @@ -281,9 +291,14 @@ export async function fetchLikedTweets( userTweetsRequest.variables['cursor'] = cursor; } + // Use bearerToken2 for UserLikedTweets endpoint const res = await requestApi( userTweetsRequest.toRequestUrl(), auth, + 'GET', + undefined, + undefined, + bearerToken2, ); if (!res.success) { diff --git a/test-setup.js b/test-setup.js index b333de2f..49c55171 100644 --- a/test-setup.js +++ b/test-setup.js @@ -1,2 +1 @@ -globalThis.PLATFORM_NODE = false; -globalThis.PLATFORM_NODE_JEST = true; +globalThis.PLATFORM_NODE = true;