summaryrefslogtreecommitdiff
path: root/packages/tweetdeck/src/lib/fetching/twitter-api.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tweetdeck/src/lib/fetching/twitter-api.ts')
-rw-r--r--packages/tweetdeck/src/lib/fetching/twitter-api.ts1178
1 files changed, 1178 insertions, 0 deletions
diff --git a/packages/tweetdeck/src/lib/fetching/twitter-api.ts b/packages/tweetdeck/src/lib/fetching/twitter-api.ts
new file mode 100644
index 0000000..8ea4709
--- /dev/null
+++ b/packages/tweetdeck/src/lib/fetching/twitter-api.ts
@@ -0,0 +1,1178 @@
+import type {
+ Tweet,
+ TweetList,
+ TweetResult,
+ TwitterBookmarkResponse,
+ TwitterList,
+ TwitterListTimelineResponse,
+ TwitterListsManagementResponse,
+ TwitterNotification,
+ TwitterNotificationsTimelineResponse,
+ TimelineEntry,
+ TwitterTimelineResponse,
+ TwitterTweetDetailResponse,
+ TwitterUserTweetsResponse,
+ APITwitterList,
+ TweetWithVisibilityResult,
+ TwitterUser,
+ RTMetadata,
+ TwitterProfilesResponse,
+ UserResult,
+} from "./types";
+import { TransactionIdGenerator } from "./python";
+
+const TWITTER_INTERNAL_API_KEY =
+ "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
+
+export class TwitterApiService {
+ cookie: string;
+ constructor(cookie: string) {
+ this.cookie = cookie;
+ }
+ // Read endpoints
+ private static readonly BOOKMARKS_URL = new URL(
+ "https://x.com/i/api/graphql/C7CReOA1R0PwKorWAxnNUQ/Bookmarks",
+ );
+ private static readonly FOLLOWING_URL = new URL(
+ "https://x.com/i/api/graphql/fhqL7Cgmvax9jOhRMOhWpA/HomeLatestTimeline",
+ );
+ private static readonly FORYOU_URL = new URL(
+ "https://x.com/i/api/graphql/sMNeM4wvNe4JnRUZ2jd2zw/HomeTimeline",
+ );
+ private static readonly LISTS_URL = new URL(
+ "https://x.com/i/api/graphql/wLXb5F6pIEOrYtTjXFLQsA/ListsManagementPageTimeline",
+ );
+ private static readonly LIST_URL = new URL(
+ "https://x.com/i/api/graphql/p-5fXSlJaR-aZ4UUBdPMAg/ListLatestTweetsTimeline",
+ );
+ private static readonly NOTIFICATIONS_URL = new URL(
+ "https://api.x.com/1.1/notifications/timeline.json",
+ );
+ private static readonly USERDATA_URL = new URL(
+ "https://x.com/i/api/graphql/2AtIgw7Kz26sV6sEBrQjSQ/UsersByRestIds",
+ );
+ private static readonly USER_URL = new URL(
+ "https://x.com/i/api/graphql/Le1DChzkS7ioJH_yEPMi3w/UserTweets",
+ );
+ private static readonly THREAD_URL = new URL(
+ "https://x.com/i/api/graphql/aTYmkYpjWyvUyrinVWSiYA/TweetDetail",
+ );
+
+ private static readonly HEADERS = {
+ "User-Agent":
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
+ Accept: "*/*",
+ Referer: "https://x.com/i/bookmarks",
+ "Content-Type": "application/json",
+ "X-Twitter-Auth-Type": "OAuth2Session",
+ "X-Twitter-Active-User": "yes",
+ "X-Twitter-Client-Language": "en",
+ };
+
+ private get csrfToken() {
+ return this.cookie.match(/ct0=([^;]+)/)?.[1];
+ }
+
+ private buildHeaders(extra?: Record<string, string>) {
+ const headers: Record<string, string> = {
+ ...TwitterApiService.HEADERS,
+ Authorization: TWITTER_INTERNAL_API_KEY,
+ Cookie: this.cookie,
+ ...(extra ?? {}),
+ };
+ const csrf = this.csrfToken;
+ if (csrf) headers["X-Csrf-Token"] = csrf;
+ return headers;
+ }
+
+ private async request(url: URL, init: RequestInit) {
+ const headers = this.buildHeaders(init.headers as Record<string, string>);
+
+ const xcs = new TransactionIdGenerator("");
+ await xcs.init();
+ const xclientid = await xcs.getTransactionId(init.method!, url.pathname);
+ headers["X-Client-Transaction-Id"] = xclientid;
+ const response = await fetch(url, { ...init, headers });
+ if (!response.ok) {
+ console.log(headers);
+ console.log(response);
+ throw new Error(
+ `Twitter API request failed: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ return await response.json();
+ }
+ async postCall(url: URL, payload: Record<string, unknown>) {
+ const body = JSON.stringify(payload);
+ return this.request(url, {
+ method: "POST",
+ body,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ async getCall(url: URL) {
+ return this.request(url, { method: "GET" });
+ }
+ async findOwn(): Promise<TwitterUser> {
+ const cookie = decodeURIComponent(this.cookie);
+ const match = cookie.match(/twid=u=([^;]+)/);
+ const id = match![1]!;
+ const profs = await this.fetchProfiles([id]);
+ return profs[0]!;
+ }
+ async fetchProfiles(userIds: string[]): Promise<TwitterUser[]> {
+ const variables = {
+ userIds,
+ };
+ const features = {
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ responsive_web_profile_redirect_enabled: false,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ };
+ const url = new URL(TwitterApiService.USERDATA_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+
+ const data = (await this.getCall(url)) as TwitterProfilesResponse;
+ const users = data?.data?.users;
+ if (!users) throw new Error("error parsing ids");
+ const parsed = users.map((u) =>
+ TwitterApiService.extractUserData(u.result),
+ );
+ return parsed;
+ }
+ async fetchForyou(cursor?: string): Promise<TweetList> {
+ const payload = {
+ variables: {
+ count: 50,
+ cursor: cursor || "DAABCgABGv-ytnDAJxEKAAIa_qVgidrhcwgAAwAAAAEAAA",
+ includePromotedContent: true,
+ latestControlAvailable: true,
+ withCommunity: true,
+ seenTweetIds: [],
+ },
+ features: {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ },
+ queryId: "sMNeM4wvNe4JnRUZ2jd2zw",
+ };
+ const data = (await this.postCall(
+ TwitterApiService.FORYOU_URL,
+ payload,
+ )) as TwitterTimelineResponse;
+ try {
+ return TwitterApiService.parseTimelineResponse(data, "foryou");
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("wtf");
+ }
+ }
+
+ async fetchFollowing(cursor?: string) {
+ const payload = {
+ variables: {
+ count: 50,
+ cursor: cursor || "DAABCgABGv-ytnDAJxEKAAIa_qVgidrhcwgAAwAAAAEAAA",
+ includePromotedContent: true,
+ latestControlAvailable: true,
+ requestContext: "launch",
+ seenTweetIds: [],
+ },
+ features: {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ },
+ queryId: "fhqL7Cgmvax9jOhRMOhWpA",
+ };
+ const data = (await this.postCall(
+ TwitterApiService.FOLLOWING_URL,
+ payload,
+ )) as TwitterTimelineResponse;
+ try {
+ return TwitterApiService.parseTimelineResponse(data, "following");
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("wtf");
+ }
+ }
+
+ async fetchList(listId: string, cursor?: string): Promise<TweetList> {
+ const variables = { listId, count: 20, cursor };
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const url = new URL(TwitterApiService.LIST_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ const data = (await this.getCall(url)) as TwitterListTimelineResponse;
+ return TwitterApiService.parseListTimelineResponse(data);
+ }
+ async fetchLists(): Promise<TwitterList[]> {
+ const variables = { count: 100 };
+
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const url = new URL(TwitterApiService.LISTS_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ const data = (await this.getCall(url)) as TwitterListsManagementResponse;
+ try {
+ return TwitterApiService.parseListsManagementResponse(data);
+ } catch (e) {
+ console.error(e);
+ // console.dir(data.data.viewer.list_management_timeline, { depth: null });
+ throw e;
+ }
+ }
+
+ async fetchNotifications(cursor?: string): Promise<TwitterNotification[]> {
+ const variables: Record<string, string | number> = {
+ include_profile_interstitial_type: 1,
+ include_blocking: 1,
+ include_blocked_by: 1,
+ include_followed_by: 1,
+ include_want_retweets: 1,
+ include_mute_edge: 1,
+ include_can_dm: 1,
+ include_can_media_tag: 1,
+ include_ext_has_nft_avatar: 1,
+ include_ext_is_blue_verified: 1,
+ include_ext_verified_type: 1,
+ include_ext_profile_image_shape: 1,
+ skip_status: 1,
+ cards_platform: "Web-12",
+ include_cards: 1,
+ include_composer_source: "true",
+ include_ext_alt_text: "true",
+ include_ext_limited_action_results: "false",
+ include_reply_count: 1,
+ tweet_mode: "extended",
+ include_entities: "true",
+ include_user_entities: "true",
+ include_ext_media_color: "true",
+ include_ext_media_availability: "true",
+ include_ext_sensitive_media_warning: "true",
+ include_ext_trusted_friends_metadata: "true",
+ send_error_codes: "true",
+ simple_quoted_tweet: "true",
+ count: 40,
+ ext: "mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,enrichments,superFollowMetadata,unmentionInfo,editControl,vibe",
+ };
+ if (cursor) {
+ variables.cursor = cursor;
+ }
+ const url = new URL(TwitterApiService.NOTIFICATIONS_URL);
+ Object.keys(variables).forEach((key) =>
+ url.searchParams.set(key, variables[key]!.toString()),
+ );
+
+ const data = (await this.getCall(
+ url,
+ )) as TwitterNotificationsTimelineResponse;
+
+ return TwitterApiService.parseNotificationsResponse(data);
+ }
+ async fetchUserTweets(userId: string, cursor?: string): Promise<TweetList> {
+ const variables = {
+ userId,
+ count: 50,
+ includePromotedContent: true,
+ withQuickPromoteEligibilityTweetFields: true,
+ withVoice: true,
+ };
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const fieldToggles = { withArticlePlainText: true };
+
+ const url = new URL(TwitterApiService.USER_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
+ const data = (await this.getCall(url)) as TwitterUserTweetsResponse;
+
+ try {
+ return TwitterApiService.parseUserTweetsResponse(data);
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("bad");
+ }
+ }
+ async fetchThread(tweetId: string, cursor?: string): Promise<TweetList> {
+ const variables = {
+ focalTweetId: tweetId,
+ referrer: "profile",
+ with_rux_injections: false,
+ rankingMode: "Relevance",
+ includePromotedContent: true,
+ withCommunity: true,
+ withQuickPromoteEligibilityTweetFields: true,
+ withBirdwatchNotes: true,
+ withVoice: true,
+ };
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const fieldToggles = {
+ withArticleRichContentState: true,
+ withArticlePlainText: true,
+ withGrokAnalyze: true,
+ withDisallowedReplyControls: false,
+ };
+
+ const url = new URL(TwitterApiService.THREAD_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
+ const data = (await this.getCall(url)) as TwitterTweetDetailResponse;
+
+ try {
+ return TwitterApiService.parseThreadResponse(data);
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("Bad");
+ }
+ }
+ async fetchBookmarks(cursor?: string): Promise<TweetList> {
+ const variables = {
+ count: 40,
+ cursor: cursor || null,
+ includePromotedContent: true,
+ };
+
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const url = new URL(TwitterApiService.BOOKMARKS_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ const data = (await this.getCall(url)) as TwitterBookmarkResponse;
+
+ return TwitterApiService.parseBookmarkResponse(data);
+ }
+
+ private static parseUserTweetsResponse(
+ response: TwitterUserTweetsResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+ const instructions =
+ response.data?.user?.result?.timeline?.timeline?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing user feed");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing user feed");
+ for (const entry of instruction.entries) {
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const tweet = this.extractTweetData(tweetData);
+ if (tweet) {
+ tweets.push(tweet);
+ }
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+
+ return { tweets, cursorBottom, cursorTop };
+ }
+
+ private static parseThreadResponse(
+ response: TwitterTweetDetailResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions =
+ response.data?.threaded_conversation_with_injections_v2?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing thread ");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing thread feed");
+ for (const entry of instruction.entries) {
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const tweet = this.extractTweetData(tweetData);
+ if (tweet) {
+ tweets.push(tweet);
+ }
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+ return { tweets, cursorBottom, cursorTop };
+ }
+
+ private static parseBookmarkResponse(
+ response: TwitterBookmarkResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions =
+ response.data?.bookmark_timeline_v2?.timeline?.instructions || [];
+
+ for (const instruction of instructions) {
+ if (
+ instruction.type === "TimelineAddEntries" ||
+ instruction.type === "TimelineReplaceEntry" ||
+ instruction.type === "TimelineShowMoreEntries"
+ ) {
+ const entries = [
+ ...(instruction.entries || []),
+ ...(((instruction as { entry?: TimelineEntry }).entry
+ ? [(instruction as { entry?: TimelineEntry }).entry!]
+ : []) as TimelineEntry[]),
+ ];
+
+ for (const entry of entries) {
+ const content = entry.content;
+
+ if (content?.entryType === "TimelineTimelineItem") {
+ const tweetData = content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const bookmark = this.extractTweetData(tweetData);
+ if (bookmark) {
+ tweets.push(bookmark);
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+ }
+
+ return {
+ tweets,
+ cursorTop,
+ cursorBottom,
+ };
+ }
+
+ private static parseTimelineResponse(
+ response: TwitterTimelineResponse,
+ type: "foryou" | "following",
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions = response.data?.home?.home_timeline_urt?.instructions;
+ response.data?.home_timeline_urt?.instructions;
+
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing thread ");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing thread feed");
+
+ for (const entry of instruction.entries) {
+ // if (entry.content.entryType.includes("ursor")) console.log(entry);
+ // if (entry.content.entryType.includes("odule"))
+ // console.log("module", entry);
+
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content?.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ try {
+ const tweet = this.extractTweetData(tweetData);
+ tweets.push(tweet);
+ } catch (e) {
+ console.error(e);
+ // console.dir(entry, { depth: null });
+ }
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Gap" // TODO wtf???
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+
+ return { tweets, cursorTop, cursorBottom };
+ }
+
+ private static parseListTimelineResponse(
+ response: TwitterListTimelineResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions =
+ response.data?.list?.tweets_timeline?.timeline?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing tw timeline res");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing tw timeline res");
+ for (const entry of instruction.entries) {
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const tweet = this.extractTweetData(tweetData);
+ tweets.push(tweet);
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+
+ return { tweets, cursorBottom, cursorTop };
+ }
+
+ private static parseListsManagementResponse(
+ response: TwitterListsManagementResponse,
+ ): TwitterList[] {
+ const lists: TwitterList[] = [];
+ const instructions =
+ response.data?.viewer?.list_management_timeline?.timeline?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing tw lists res");
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction?.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing tw lists res 2");
+ for (const entry of instruction.entries) {
+ console.log("entry", entry.content.__typename);
+ // if (entry.content.__typename === "TimelineTimelineModule")
+ if (entry.content.__typename === "TimelineTimelineCursor") {
+ console.dir(entry, { depth: null });
+ continue;
+ }
+ // entry.content.entryType can be TimelineTimelineModule, TimelineTimelineCursor,
+ // entry.entryId can be list-to-follow-<bignumber> which si the recommended lists that show on top
+ // or owned-subscribed-list-module-<smolnum> which is what we want
+ const listList = entry?.content?.items;
+ if (!listList || !Array.isArray(listList))
+ throw new Error("error parsing tw lists res 3");
+ for (const list of listList) {
+ lists.push(this.parseListResponse(list.item.itemContent.list));
+ }
+ }
+ }
+ }
+ return lists;
+ }
+
+ private static parseListResponse(res: APITwitterList): TwitterList {
+ const { name, id_str, member_count, subscriber_count } = res;
+ const creator = res.user_results.result.core.name;
+ return { name, id: id_str, member_count, subscriber_count, creator };
+ }
+ private static parseNotificationsResponse(
+ response: TwitterNotificationsTimelineResponse,
+ ): TwitterNotification[] {
+ const notifications: TwitterNotification[] = [];
+ const timelineNotifs =
+ response.timeline.instructions[0]?.addEntries?.entries;
+ if (!timelineNotifs || !Array.isArray(timelineNotifs))
+ throw new Error("error parsing notifs");
+ for (const entry of timelineNotifs) {
+ const notificationId = entry.content.notification.id;
+ const notification = response.globalObjects.notifications[notificationId];
+ if (notification) {
+ notifications.push(notification);
+ }
+ }
+ return notifications;
+ }
+ private static extractUserData(userResults: UserResult): TwitterUser {
+ return {
+ id: userResults.rest_id,
+ avatar:
+ userResults.avatar?.image_url ||
+ userResults.legacy?.profile_image_url_https!,
+ name: userResults.legacy?.name || userResults.core?.name!,
+ username:
+ userResults.legacy?.screen_name || userResults.core?.screen_name!,
+ };
+ }
+
+ private static extractTweetData(
+ tweetRes: TweetResult | TweetWithVisibilityResult,
+ rter: RTMetadata | null = null,
+ ): Tweet {
+ const tweetData =
+ tweetRes.__typename === "Tweet" ? tweetRes : tweetRes.tweet;
+
+ console.log({ tweetData });
+ let quoting: Tweet | null = null;
+ let retweeted_by = rter;
+ const legacy = tweetData?.legacy;
+ const userResults = tweetData?.core?.user_results?.result;
+ // if (!legacy || !userResults) throw new Error("no legacy??");
+ if (!legacy) throw new Error("no legacy??");
+ if (!userResults) throw new Error("no userResults??");
+
+ const author = this.extractUserData(userResults);
+ const time = new Date(legacy.created_at).getTime();
+
+ // is_rt
+ if (legacy.retweeted_status_result) {
+ const d = legacy.retweeted_status_result.result;
+ if (!d) console.log("bad rt", tweetData);
+ return this.extractTweetData(legacy.retweeted_status_result.result, {
+ author,
+ time,
+ });
+ }
+ //
+
+ // quotes
+ if (
+ tweetData.quoted_status_result &&
+ tweetData.quoted_status_result.result
+ ) {
+ // const d = tweetData.quoted_status_result.result;
+ // if (!d) console.log("bad quote", tweetData);
+ quoting = this.extractTweetData(tweetData.quoted_status_result.result);
+ }
+ //
+ const mediaEntities = legacy.entities.media;
+ // if (!mediaEntities) {
+ // console.log("no media");
+ // console.dir(legacy.entities, { depth: null });
+ // }
+ const media = (mediaEntities || []).reduce(
+ (
+ acc: { pics: string[]; video: { thumb: string; url: string } },
+ item,
+ ) => {
+ if (item.type === "photo" && item.media_url_https) {
+ return {
+ pics: [...acc.pics, item.media_url_https],
+ video: acc.video,
+ };
+ }
+ if (item.type === "video" && item.video_info?.variants) {
+ const video = item.video_info.variants.reduce(
+ (
+ acc: { bitrate?: number; url: string },
+ vid: { bitrate?: number; url: string },
+ ) => {
+ if (!vid.bitrate) return acc;
+ if (!acc.bitrate || vid.bitrate > acc.bitrate) return vid;
+ return acc;
+ },
+ { url: "" },
+ );
+ return {
+ pics: acc.pics,
+ video: {
+ url: video.url!,
+ thumb: item.media_url_https!,
+ },
+ };
+ }
+ return acc;
+ },
+ { pics: [] as string[], video: { thumb: "", url: "" } },
+ );
+ if (legacy.full_text.includes("your computers"))
+ console.dir(tweetRes, { depth: null });
+ const replyingTo = legacy.entities?.user_mentions
+ ? legacy.entities.user_mentions.map((m) => ({
+ name: m.name,
+ username: m.screen_name,
+ id: m.id_str,
+ }))
+ : [];
+
+ return {
+ id: tweetData.rest_id,
+ text: legacy.display_text_range
+ ? legacy.full_text.slice(
+ legacy.display_text_range[0],
+ legacy.display_text_range[1] + 1,
+ )
+ : legacy.full_text!,
+ language: legacy.lang || "en",
+ author,
+ time,
+ urls:
+ legacy.entities?.urls?.map(
+ (url: { expanded_url: string; display_url: string }) => ({
+ expandedUrl: url.expanded_url,
+ displayUrl: url.display_url,
+ }),
+ ) || [],
+ media,
+ hashtags:
+ legacy.entities?.hashtags?.map((tag: { text: string }) => tag.text) ||
+ [],
+ quoting,
+ retweeted_by,
+ liked: legacy.favorited,
+ bookmarked: legacy.bookmarked,
+ rted: legacy.retweeted,
+ replyingTo,
+ };
+ }
+
+ async fetchAllBookmarks(): Promise<Tweet[]> {
+ const allBookmarks: Tweet[] = [];
+ let cursor: string | undefined;
+ let hasMore = true;
+
+ while (hasMore) {
+ try {
+ const result = await this.fetchBookmarks(cursor);
+ allBookmarks.push(...result.tweets);
+ cursor = result.cursorBottom;
+
+ // Rate limiting - be nice to Twitter's API
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ } catch (error) {
+ console.error("Error fetching bookmarks batch:", error);
+ break;
+ }
+ }
+
+ return allBookmarks;
+ }
+ // WRITE ENDPOINTS
+ // TODO Grok stuff
+ //
+ // TODO add images, polls etc.
+ // quote is the URL https;//x.com/{user}/status/{id} of the quoted tweet;
+ async createTweet(text: string, topts: { quote?: string; reply?: string }) {
+ const queryId = `-fU2A9SG7hdlzUdOh04POw`;
+ const url = `https://x.com/i/api/graphql/${queryId}/createTweet`;
+ let variables: any = {
+ tweet_text: text,
+ dark_request: false,
+ media: {
+ media_entities: [],
+ possibly_sensitive: false,
+ },
+ semantic_annotation_ids: [],
+ disallowed_reply_options: null,
+ };
+ const features = {
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ responsive_web_profile_redirect_enabled: false,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ articles_preview_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_imagine_annotation_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ if (topts.reply)
+ variables = {
+ ...variables,
+ reply: {
+ in_reply_to_tweet_id: topts.reply,
+ exclude_reply_user_ids: [],
+ },
+ };
+ if (topts.quote) variables = { ...variables, attachment_url: topts.quote };
+
+ const params = { ...features, variables, queryId };
+ const body = JSON.stringify(params);
+ const nheaders = { "Content-type": "application/json" };
+ const headers = {
+ ...TwitterApiService.HEADERS,
+ Authorization: TWITTER_INTERNAL_API_KEY,
+ Cookie: this.cookie,
+ ...nheaders,
+ };
+ const opts = {
+ method: "POST",
+ headers,
+ body,
+ };
+ const res = await fetch(url, opts);
+ console.log("like added", res);
+ }
+ async addLike(tweet_id: string) {
+ const queryId = `lI07N6Otwv1PhnEgXILM7A`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/FavoriteTweet`);
+ const body = { variables: { tweet_id }, queryId };
+ return await this.postCall(url, body);
+ }
+ async removeLike(tweet_id: string) {
+ const queryId = `ZYKSe-w7KEslx3JhSIk5LA`;
+ const url = new URL(
+ `https://x.com/i/api/graphql/${queryId}/UnfavoriteTweet`,
+ );
+ const body = { variables: { tweet_id }, queryId };
+ return await this.postCall(url, body);
+ }
+ async addRT(tweet_id: string) {
+ const queryId = `ZYKSe-w7KEslx3JhSIk5LA`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/CreateRetweet`);
+ // TODO wtf is dark_request bruh
+ const body = {
+ variables: { tweet_id, dark_request: false },
+ queryId,
+ };
+ return await this.postCall(url, body);
+ }
+ async removeRT(tweet_id: string) {
+ const queryId = `iQtK4dl5hBmXewYZuEOKVw`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/DeleteRetweet`);
+ const body = {
+ variables: { tweet_id, dark_request: false },
+ queryId,
+ };
+ return await this.postCall(url, body);
+ }
+ async removeTweet(tweet_id: string) {
+ const queryId = `VaenaVgh5q5ih7kvyVjgtg`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/DeleteTweet`);
+ const body = {
+ variables: { tweet_id, dark_request: false },
+ queryId,
+ };
+ return await this.postCall(url, body);
+ }
+
+ async addBookmark(tweet_id: string) {
+ const queryId = `aoDbu3RHznuiSkQ9aNM67Q`;
+ const url = new URL(
+ `https://x.com/i/api/graphql/${queryId}/CreateBookmark`,
+ );
+ const body = { variables: { tweet_id }, queryId };
+ try {
+ const res = await this.postCall(url, body);
+ return res?.data?.tweet_bookmark_put === "Done";
+ } catch (e) {
+ console.log("wtf man", e);
+ // return this.removeBookmark(tweet_id);
+ }
+ }
+ async removeBookmark(tweet_id: string) {
+ const queryId = `Wlmlj2-xzyS1GN3a6cj-mQ`;
+ const url = new URL(
+ `https://x.com/i/api/graphql/${queryId}/DeleteBookmark`,
+ );
+ const body = { variables: { tweet_id }, queryId };
+ const res = await this.postCall(url, body);
+ return res?.data?.tweet_bookmark_delete === "Done";
+ }
+}