diff options
Diffstat (limited to 'app/src/lib/twitter-api.ts')
-rw-r--r-- | app/src/lib/twitter-api.ts | 308 |
1 files changed, 308 insertions, 0 deletions
diff --git a/app/src/lib/twitter-api.ts b/app/src/lib/twitter-api.ts new file mode 100644 index 0000000..6dc41c0 --- /dev/null +++ b/app/src/lib/twitter-api.ts @@ -0,0 +1,308 @@ +const TWITTER_INTERNAL_API_KEY = + "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"; + +interface TwitterBookmarkResponse { + data: { + bookmark_timeline_v2: { + timeline: { + instructions: Array<{ + type: string; + entries?: Array<{ + content: { + entryType: string; + itemContent?: { + tweet_results: { + result: { + rest_id: string; + core: { + user_results: { + result: { + name: string; + screen_name: string; + }; + }; + }; + legacy: { + full_text: string; + created_at: string; + entities: { + urls?: Array<{ + expanded_url: string; + display_url: string; + }>; + media?: Array<{ + expanded_url: string; + display_url: string; + media_url_https: string; + media_key: string; + type: "photo" | "string"; + }>; + hashtags?: Array<{ + text: string; + }>; + }; + }; + }; + }; + }; + }; + }>; + }>; + }; + }; + }; +} + +export interface TwitterBookmark { + id: string; + text: string; + language: string; + author: { + avatar: string; + name: string; + username: string; + }; + createdAt: string; + urls: Array<{ + expandedUrl: string; + displayUrl: string; + }>; + media: { pics: string[]; video: { thumb: string; url: string } }; + hashtags: string[]; +} + +export class TwitterApiService { + cookie: string; + constructor(cookie: string) { + this.cookie = cookie; + } + private static readonly BOOKMARKS_URL = + "https://x.com/i/api/graphql/C7CReOA1R0PwKorWAxnNUQ/Bookmarks"; + + private static readonly 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, + }; + + 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", + }; + + async fetchBookmarks(cursor?: string): Promise<{ + bookmarks: TwitterBookmark[]; + nextCursor?: string; + hasMore: boolean; + }> { + const variables = { + count: 40, + cursor: cursor || null, + includePromotedContent: true, + }; + + const uri = new URL(TwitterApiService.BOOKMARKS_URL); + uri.searchParams.set("variables", JSON.stringify(variables)); + uri.searchParams.set( + "features", + JSON.stringify(TwitterApiService.FEATURES), + ); + const url = uri.toString(); + const csrfm = this.cookie.match(/ct0=([^;]+)/); + const csrf = csrfm![1]; + const nheaders = { "X-Csrf-Token": csrf! }; + + const headers = { + ...TwitterApiService.HEADERS, + Authorization: TWITTER_INTERNAL_API_KEY, + Cookie: this.cookie, + ...nheaders, + }; + const response = await fetch(url, { + method: "GET", + headers, + }); + + if (!response.ok) { + throw new Error( + `Twitter API request failed: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as TwitterBookmarkResponse; + + return TwitterApiService.parseBookmarkResponse(data); + } + + private static parseBookmarkResponse(response: TwitterBookmarkResponse): { + bookmarks: TwitterBookmark[]; + nextCursor?: string; + hasMore: boolean; + } { + const bookmarks: TwitterBookmark[] = []; + let nextCursor: string | undefined; + let hasMore = false; + + const instructions = + response.data?.bookmark_timeline_v2?.timeline?.instructions || []; + + for (const instruction of instructions) { + if (instruction.type === "TimelineAddEntries") { + for (const entry of instruction.entries || []) { + if (entry.content.entryType === "TimelineTimelineItem") { + const tweetData = entry.content.itemContent?.tweet_results?.result; + if (tweetData) { + const bookmark = this.extractBookmarkData(tweetData); + if (bookmark) { + bookmarks.push(bookmark); + } + } + } + } + } + } + + const ret = { bookmarks, hasMore }; + return nextCursor ? { ...ret, nextCursor } : ret; + } + + private static extractBookmarkData(tweetData: any): TwitterBookmark | null { + try { + const legacy = tweetData.legacy; + const userResults = tweetData.core?.user_results?.result; + + if (!legacy || !userResults) { + return null; + } + const mediaEntities = legacy.entities?.media || []; + const media = mediaEntities.reduce( + (acc: { pics: string[]; video: string }, item: any) => { + if (item.type === "photo") + return { + pics: [...acc.pics, item.media_url_https], + video: acc.video, + }; + if (item.type === "video") { + const video = item.video_info.variants.reduce( + (acc: any, vid: any) => { + if (!vid.bitrate) return acc; + if (vid.bitrate > acc.bitrate) return vid; + else return acc; + }, + {}, + ); + return { + pics: acc.pics, + video: { url: video.url, thumb: video.media_url_https }, + }; + } + }, + { pics: [], video: { thumb: "", url: "" } }, + ); + + return { + id: tweetData.rest_id, + text: legacy.full_text.slice( + legacy.display_text_range[0], + legacy.display_text_range[1] + 1, + ), + language: legacy.lang, + author: { + avatar: userResults.avatar.image_url, + name: userResults.core.name, + username: userResults.core.screen_name, + }, + createdAt: legacy.created_at, + urls: + legacy.entities?.urls?.map((url: any) => ({ + expandedUrl: url.expanded_url, + displayUrl: url.display_url, + })) || [], + media, + hashtags: legacy.entities?.hashtags?.map((tag: any) => tag.text) || [], + }; + } catch (error) { + console.error("Error extracting bookmark data:", error); + return null; + } + } + + async fetchAllBookmarks(): Promise<TwitterBookmark[]> { + const allBookmarks: TwitterBookmark[] = []; + let cursor: string | undefined; + let hasMore = true; + + while (hasMore) { + try { + const result = await this.fetchBookmarks(cursor); + allBookmarks.push(...result.bookmarks); + cursor = result.nextCursor; + hasMore = result.hasMore && !!cursor; + + // 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; + } + async removeBookmark(tweet_id: string) { + const queryId = `Wlmlj2-xzyS1GN3a6cj-mQ`; + const url = `https://x.com/i/api/graphql/${queryId}/DeleteBookmark`; + const body = JSON.stringify({ variables: { tweet_id }, queryId }); + 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("bookmark deleted", res); + } +} |