summaryrefslogtreecommitdiff
path: root/app/src/lib/twitter-api.ts
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/lib/twitter-api.ts')
-rw-r--r--app/src/lib/twitter-api.ts308
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);
+ }
+}