diff options
Diffstat (limited to 'packages/tweetdeck/src/lib/fetching/twitter-api.ts')
| -rw-r--r-- | packages/tweetdeck/src/lib/fetching/twitter-api.ts | 1178 |
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"; + } +} |
