システムのAPIを設計する際に、ソフトウェアエンジニアは次のようなさまざまなオプションを検討することが多い。 (またはその他のハイブリッド アプローチ) を使用して、特定のタスクまたはプロジェクトに最適なものを決定します。 REST 対 RPC 対 GraphQL この記事では、 ( ) ホーム タイムライン (x.com/home) API の設計方法と、次の課題を解決するためにどのようなアプローチが使用されているかについて説明します。 X Twitter ツイートのリストを取得する方法 並べ替えとページ付けの方法 階層的/リンクされたエンティティ(ツイート、ユーザー、メディア)を返す方法 ツイートの詳細を取得する方法 ツイートを「いいね」する方法 バックエンド コード自体にはアクセスできないため、バックエンドの実装はブラック ボックスとして扱い、API レベルでのみこれらの課題を検討します。 ここで正確なリクエストとレスポンスを示すのは、深くネストされた反復的なオブジェクトが読みにくいため、面倒で理解しにくいかもしれません。リクエスト/レスポンスのペイロード構造をわかりやすくするために、ホーム タイムライン API を TypeScript で「入力」してみました。そのため、リクエスト/レスポンスの例では、実際の JSON オブジェクトではなく、リクエスト タイプとレスポンス 使用します。また、簡潔にするためにタイプが簡略化され、多くのプロパティが省略されていることに注意してください。 タイプを すべてのタイプが見つかります タイプ/x.ts ファイルまたはこの記事の下部にある「付録: すべてのタイプを 1 か所にまとめる」セクションを参照してください。 ツイートのリストを取得しています エンドポイントとリクエスト/レスポンス構造 ホーム タイムラインのツイートのリストを取得するには、次のエンドポイントへの リクエストから始まります。 POST POST https://x.com/i/api/graphql/{query-id}/HomeTimeline 簡略化された 本文のタイプは次のとおりです。 リクエスト type TimelineRequest = { queryId: string; // 's6ERr1UxkxxBx4YundNsXw' variables: { count: number; // 20 cursor?: string; // 'DAAACgGBGedb3Vx__9sKAAIZ5g4QENc99AcAAwAAIAIAAA' seenTweetIds: string[]; // ['1867041249938530657', '1867041249938530659'] }; features: Features; }; type Features = { articles_preview_enabled: boolean; view_counts_everywhere_api_enabled: boolean; // ... } 以下は簡略化された 本文のタイプです (レスポンスのサブタイプについては以下で詳しく説明します)。 レスポンス type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; responseObjects: { feedbackActions: TimelineAction[]; }; }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; }; type TimelineItem = { entryId: string; // 'tweet-1867041249938530657' sortIndex: string; // '1866561576636152411' content: { __typename: 'TimelineTimelineItem'; itemContent: TimelineTweet; feedbackInfo: { feedbackKeys: ActionKey[]; // ['-1378668161'] }; }; }; type TimelineTweet = { __typename: 'TimelineTweet'; tweet_results: { result: Tweet; }; }; type TimelineCursor = { entryId: string; // 'cursor-top-1867041249938530657' sortIndex: string; // '1866961576813152212' content: { __typename: 'TimelineTimelineCursor'; value: string; // 'DACBCgABGedb4VyaJwuKbIIZ40cX3dYwGgaAAwAEAEEAA' cursorType: 'Top' | 'Bottom'; }; }; type ActionKey = string; ここで注目すべき興味深い点は、データの「取得」が「POST」を介して行われることです。これは、REST のような API では一般的ではありませんが、GraphQL のような API では一般的です。また、URL の 部分は、X が API に GraphQL フレーバーを使用していることを示しています。 graphql ここで という言葉を使うのは、リクエスト本体自体が純粋な ここで、取得したいすべてのプロパティをリストして、必要なレスポンス構造を記述できます。 「フレーバー」 GraphQLクエリ # An example of a pure GraphQL request structure that is *not* being used in the X API. { tweets { id description created_at medias { kind url # ... } author { id name # ... } # ... } } ここでの前提は、ホーム タイムライン API は純粋な GraphQL API ではなく、 であるということです。このように POST リクエストでパラメータを渡すことは、「機能的な」RPC 呼び出しに近いようです。しかし同時に、GraphQL 機能は エンドポイント ハンドラ/コントローラの背後にあるバックエンドのどこかで使用される可能性があるようです。このような組み合わせは、レガシー コードまたは進行中の移行によっても発生する可能性があります。ただし、繰り返しますが、これらは単なる私の推測です。 いくつかのアプローチを組み合わせたもの HomeTimeline また、API URL と API リクエスト本文で同じ が使用されていることにも気付くかもしれません。この queryId はおそらくバックエンドで生成され、 バンドルに埋め込まれ、バックエンドからデータを取得するときに使用されます。X のバックエンドは私たちの場合ブラック ボックスであるため、この がどのように使用されるのか正確に理解するのは困難です。ただし、ここでの推測としては、何らかのパフォーマンス最適化 (事前に計算されたクエリ結果の再利用など)、キャッシュ (Apollo 関連など)、デバッグ (queryId によるログの結合など)、または追跡/トレースの目的で必要になる可能性があると考えられます。 TimelineRequest.queryId main.js queryId また、 にはツイートのリストが含まれているのではなく、 ( 型を参照) や ( 型を参照) などの 含まれていることも興味深い点です。 TimelineResponse 「タイムラインにツイートを追加する」 TimelineAddEntries 「タイムラインを終了する」 TimelineTerminateTimeline 指示のリストが 命令自体にも、さまざまな種類のエンティティが含まれる場合があります。 TimelineAddEntries ツイート — タイプを参照 TimelineItem カーソル — 型を参照 TimelineCursor 会話/コメント/スレッド — タイプを参照 TimelineModule type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // <-- Here // ... }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // <-- Here }; これは、API をあまり調整しなくてもホーム タイムラインでレンダリングできる内容の幅が広がるため、拡張性の観点から興味深いものです。 ページネーション プロパティは、一度に取得するツイートの数 (ページあたり) を設定します。デフォルトは 20 です。ただし、 配列で 20 を超えるツイートが返されることがあります。たとえば、最初のページの読み込みでは、配列に 37 のエントリが含まれる場合があります。これは、ツイート (29)、固定されたツイート (1)、プロモーションされたツイート (5)、およびページネーション カーソル (2) が含まれているためです。ただし、要求された数が 20 であるのに、通常のツイートが 29 個ある理由はわかりません。 TimelineRequest.variables.count TimelineAddEntries.entries は、カーソルベースのページ区切りを担当します。 TimelineRequest.variables.cursor 「 新しいレコードが追加される頻度が高く、データを読み取るときに最新の結果を最初に確認することが多いため、リアルタイムデータに最もよく使用されます。これにより、項目をスキップしたり、同じ項目を複数回表示したりする可能性がなくなります。カーソルベースのページネーションでは、定数ポインタ(またはカーソル)を使用して、データセットのどこから次の項目を取得するかを追跡します。」 カーソルによるページネーションは、 オフセット ページネーションとカーソル ページネーション コンテキストのスレッド。 初めてツイートのリストを取得するときは、デフォルトの(おそらく事前に計算された)パーソナライズされたツイートのリストから上位のツイートを取得するため、 は空です。 TimelineRequest.variables.cursor ただし、レスポンスでは、ツイート データとともに、バックエンドはカーソル エントリも返します。レスポンス タイプの階層は次のとおりです: : TimelineResponse → TimelineAddEntries → TimelineCursor type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // <-- Here // ... }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // <-- Here (tweets + cursors) }; type TimelineCursor = { entryId: string; sortIndex: string; content: { __typename: 'TimelineTimelineCursor'; value: string; // 'DACBCgABGedb4VyaJwuKbIIZ40cX3dYwGgaAAwAEAEEAA' <-- Here cursorType: 'Top' | 'Bottom'; }; }; 各ページには、ツイートのリストと「上」と「下」のカーソルが表示されます。 ページ データが読み込まれた後、現在のページから両方向に移動して、「下」カーソルを使用して「前の/古い」ツイートを取得するか、「上」カーソルを使用して「次の/新しい」ツイートを取得できます。私の推測では、「上」カーソルを使用して「次の」ツイートを取得するのは、ユーザーが現在のページを読んでいる間に新しいツイートが追加された場合、またはユーザーがフィードを上方向にスクロールし始めた場合 (キャッシュされたエントリがない場合、またはパフォーマンス上の理由で前のエントリが削除された場合) の 2 つのケースで発生します。 X のカーソル自体は、次のようになります: 。一部の API 設計では、カーソルはリストの最後のエントリの ID または最後に表示されたエントリのタイムスタンプを含む Base64 でエンコードされた文字列である場合があります。たとえば、 で、このデータはそれに応じてデータベースを照会するために使用されます。X API の場合、カーソルはカスタム バイナリ シーケンスに Base64 デコードされているように見えますが、意味を理解するにはさらにデコードが必要になる可能性があります (つまり、Protobuf メッセージ定義を介して)。それが エンコーディングであるかどうかは不明であり、また メッセージ定義も不明であるため、バックエンドがカーソル文字列に基づいて次のツイートのバッチを照会する方法を知っていると想定できます。 DAABCgABGemI6Mk__9sKAAIZ6MSYG9fQGwgAAwAAAAIAAA eyJpZCI6ICIxMjM0NTY3ODkwIn0= --> {"id": "1234567890"} .proto .proto パラメータは、無限スクロールの現在アクティブなページからクライアントがすでに見たツイートをサーバーに通知するために使用されます。これにより、サーバーが結果の後続ページに重複したツイートを含めないようにすることができます。 TimelineResponse.variables.seenTweetIds リンクされた/階層化されたエンティティ ホーム タイムライン (またはホーム フィード) などの API で解決すべき課題の 1 つは、リンクされたエンティティまたは階層的なエンティティ ( 、 、 など) を返す方法を見つけることです。 tweet → user tweet → media media → author 最初にツイートのリストのみを返し、その後、依存エンティティ (ユーザーの詳細など) をオンデマンドで個別のクエリで取得する必要がありますか? それとも、すべてのデータを一度に返して、最初のロードの時間とサイズを増やして、後続のすべての呼び出しの時間を節約するべきでしょうか? この場合、ペイロード サイズを減らすためにデータを正規化する必要がありますか (つまり、同じユーザーが多くのツイートの作成者であり、各ツイート エンティティでユーザー データを何度も繰り返すことを避けたい場合)? それとも、上記のアプローチを組み合わせたほうがよいでしょうか? X がそれをどのように処理するか見てみましょう。 先ほど、 タイプで サブタイプが使用されました。どのように見えるか見てみましょう。 TimelineTweet Tweet export type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // <-- Here // ... }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // <-- Here }; type TimelineItem = { entryId: string; sortIndex: string; content: { __typename: 'TimelineTimelineItem'; itemContent: TimelineTweet; // <-- Here // ... }; }; type TimelineTweet = { __typename: 'TimelineTweet'; tweet_results: { result: Tweet; // <-- Here }; }; // A Tweet entity type Tweet = { __typename: 'Tweet'; core: { user_results: { result: User; // <-- Here (a dependent User entity) }; }; legacy: { full_text: string; // ... entities: { // <-- Here (a dependent Media entities) media: Media[]; hashtags: Hashtag[]; urls: Url[]; user_mentions: UserMention[]; }; }; }; // A User entity type User = { __typename: 'User'; id: string; // 'VXNlcjoxNDUxM4ADSG44MTA4NDc4OTc2' // ... legacy: { location: string; // 'San Francisco' name: string; // 'John Doe' // ... }; }; // A Media entity type Media = { // ... source_user_id_str: string; // '1867041249938530657' <-- Here (the dependant user is being mentioned by its ID) url: string; // 'https://t.co/X78dBgtrsNU' features: { large: { faces: FaceGeometry[] }; medium: { faces: FaceGeometry[] }; small: { faces: FaceGeometry[] }; orig: { faces: FaceGeometry[] }; }; sizes: { large: MediaSize; medium: MediaSize; small: MediaSize; thumb: MediaSize; }; video_info: VideoInfo[]; }; ここで興味深いのは、 や などの依存データのほとんどが最初の呼び出しの応答に埋め込まれていることです (後続のクエリはありません)。 tweet → media tweet → author また、 エンティティとの および 接続は正規化されていません (2 つのツイートの著者が同じ場合、そのデータは各ツイート オブジェクトで繰り返されます)。ただし、特定のユーザーのホーム タイムラインの範囲内では、ツイートは多くの著者によって作成され、繰り返しは可能ですがまばらであるため、問題ないと思われます。 Tweet User Media のツイートを取得する API (ここでは説明しません) は別の方法で処理するだろうと想定していましたが、どうやらそうではないようです。UserTweets 同じユーザーのツイートのリストを返し、ツイートごとに同じユーザー データを何度も埋め込みます。興味深いですね。おそらく、このアプローチのシンプルさが、データ サイズのオーバーヘッド (ユーザー データのサイズはかなり小さいと考えられる) を上回っているのでしょう。よくわかりません。 特定のユーザー UserTweets UserTweets エンティティの関係に関するもう 1 つの観察点は、 エンティティにも (作成者) へのリンクがあることです。ただし、 エンティティのように直接エンティティを埋め込むのではなく、 プロパティを介してリンクします。 Media User Tweet Media.source_user_id_str ホーム タイムラインの各「ツイート」の「コメント」(本質的には「ツイート」でもある) はまったく取得されません。ツイート スレッドを表示するには、ユーザーはツイートをクリックして詳細ビューを表示する必要があります。ツイート スレッドは、 エンドポイントを呼び出すことによって取得されます (詳細については、以下の「ツイートの詳細ページ」セクションを参照してください)。 TweetDetail 各 が持つもう 1 つのエンティティは、 です (つまり、「あまり頻繁におすすめしない」または「表示回数を減らす」)。レスポンス オブジェクトでの の保存方法は、 オブジェクトや オブジェクトの保存方法とは異なります。 と エンティティは の一部ですが、 配列に個別に保存され、 を介してリンクされます。これは私にとってはちょっとした驚きでした。なぜなら、どのアクションも再利用できるわけではないようです。1 つのアクションは 1 つの特定のツイートにのみ使用されるようです。したがって、 は、 エンティティと同じように各ツイートに埋め込むことができるようです。ただし、ここでは隠れた複雑さを見逃している可能性があります (各アクションに子アクションがある可能性があるなど)。 Tweet FeedbackActions FeedbackActions User Media User Media Tweet FeedbackActions TimelineItem.content.feedbackInfo.feedbackKeys ActionKey FeedbackActions Media アクションの詳細については、以下の「ツイート アクション」セクションをご覧ください。 ソート タイムライン エントリの並べ替え順序は、 プロパティを介してバックエンドによって定義されます。 sortIndex type TimelineCursor = { entryId: string; sortIndex: string; // '1866961576813152212' <-- Here content: { __typename: 'TimelineTimelineCursor'; value: string; cursorType: 'Top' | 'Bottom'; }; }; type TimelineItem = { entryId: string; sortIndex: string; // '1866561576636152411' <-- Here content: { __typename: 'TimelineTimelineItem'; itemContent: TimelineTweet; feedbackInfo: { feedbackKeys: ActionKey[]; }; }; }; type TimelineModule = { entryId: string; sortIndex: string; // '73343543020642838441' <-- Here content: { __typename: 'TimelineTimelineModule'; items: { entryId: string, item: TimelineTweet, }[], displayType: 'VerticalConversation', }; }; 自体は のような感じになるかもしれません。これはおそらく、 。 sortIndex '1867231621095096312' スノーフレークID のようになります。 実際、応答に表示される ID (ツイート ID) のほとんどは「Snowflake ID」規則に従っており、 '1867231621095096312' これをツイートなどのエンティティの並べ替えに使用すると、システムは Snowflake ID の固有の時系列並べ替えを活用します。sortIndex 値が高いツイートまたはオブジェクト (タイムスタンプが新しいもの) はフィード内の上位に表示され、値が低いツイートまたはオブジェクト (タイムスタンプが古いもの) はフィード内の下位に表示されます。 以下は、Snowflake ID (この場合は ) のデコードを段階的に説明したものです。 sortIndex 1867231621095096312 を抽出します: タイムスタンプ タイムスタンプは、Snowflake ID を 22 ビット右シフトして導出されます (データセンター、ワーカー ID、シーケンスの下位 22 ビットを削除するため)。1867231621095096312 1867231621095096312 → 445182709954 を追加: Twitterのエポック このタイムスタンプに Twitter のカスタムエポック (1288834974657) を追加すると、UNIX タイムスタンプはミリ秒単位で次のようになります: 445182709954 + 1288834974657 → 1734017684611ms に変換します: 人間が読める日付 UNIX タイムスタンプを UTC 日時に変換すると、次のようになります: 1734017684611ms → 2024-12-12 15:34:44.611 (UTC) したがって、ここではホームタイムラインのツイートが時系列順に並べられていると想定できます。 ツイートアクション 各ツイートには「アクション」メニューがあります。 各ツイートのアクションは、バックエンドの 配列から取得され、 を介してツイートにリンクされます。 TimelineItem.content.feedbackInfo.feedbackKeys ActionKey type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; responseObjects: { feedbackActions: TimelineAction[]; // <-- Here }; }; }; }; }; type TimelineItem = { entryId: string; sortIndex: string; content: { __typename: 'TimelineTimelineItem'; itemContent: TimelineTweet; feedbackInfo: { feedbackKeys: ActionKey[]; // ['-1378668161'] <-- Here }; }; }; type TimelineAction = { key: ActionKey; // '-609233128' value: { feedbackType: 'NotRelevant' | 'DontLike' | 'SeeFewer'; // ... prompt: string; // 'This post isn't relevant' | 'Not interested in this post' | ... confirmation: string; // 'Thanks. You'll see fewer posts like this.' childKeys: ActionKey[]; // ['1192182653', '-1427553257'], ie NotInterested -> SeeFewer feedbackUrl: string; // '/2/timeline/feedback.json?feedback_type=NotRelevant&action_metadata=SRwW6oXZadPHiOczBBaAwPanEwE%3D' hasUndoAction: boolean; icon: string; // 'Frown' }; }; ここで興味深いのは、アクションのこのフラットな配列が実際にはツリー (またはグラフ? 確認していません) であることです。これは、各アクションに子アクションがある可能性があるためです ( 配列を参照)。これは、たとえば、ユーザーが アクションをクリックした後、ユーザーがツイートをいいねしない理由を説明する方法として、フォローアップ アクションが表示される可能性がある場合に意味があります。 TimelineAction.value.childKeys 「いいねしない」 として「この投稿は関連性がありません」 ツイート詳細ページ ユーザーがツイートの詳細ページ(コメント/ツイートのスレッド)を表示したい場合、ユーザーはツイートをクリックし、次のエンドポイントへの リクエストが実行されます。 GET GET https://x.com/i/api/graphql/{query-id}/TweetDetail?variables={"focalTweetId":"1867231621095096312","referrer":"home","controller_data":"DACABBSQ","rankingMode":"Relevance","includePromotedContent":true,"withCommunity":true}&features={"articles_preview_enabled":true} ここで、ツイートのリストが 呼び出しで取得されているのに、各ツイートの詳細は 呼び出しで取得されているのはなぜか、気になりました。一貫性がないようです。特に、 、 などの同様のクエリ パラメータは、リクエスト ボディではなく URL で渡されることに留意してください。応答形式も同様で、リスト呼び出しからタイプを再利用しています。その理由はよくわかりません。しかし、ここでも、背景の複雑さを見逃している可能性があります。 POST GET query-id features 簡略化されたレスポンス本文のタイプは次のとおりです。 type TweetDetailResponse = { data: { threaded_conversation_with_injections_v2: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[], }, }, } type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; }; type TimelineTerminateTimeline = { type: 'TimelineTerminateTimeline', direction: 'Top', } type TimelineModule = { entryId: string; // 'conversationthread-58668734545929871193' sortIndex: string; // '1867231621095096312' content: { __typename: 'TimelineTimelineModule'; items: { entryId: string, // 'conversationthread-1866876425669871193-tweet-1866876038930951193' item: TimelineTweet, }[], // Comments to the tweets are also tweets displayType: 'VerticalConversation', }; }; 応答はリスト応答と(そのタイプにおいて)非常に似ているため、ここではあまり長く説明しません。 興味深いニュアンスの 1 つは、各ツイートの「コメント」(または会話) が実際には他のツイートであることです ( タイプを参照)。そのため、ツイート スレッドは、 エントリのリストを表示することで、ホーム タイムライン フィードと非常によく似ています。これはエレガントに見えます。これは、API 設計に対する汎用的で再利用可能なアプローチの良い例です。 TimelineModule TimelineTweet ツイートにいいね ユーザーがツイートに「いいね!」すると、次のエンドポイントへの リクエストが実行されます。 POST POST https://x.com/i/api/graphql/{query-id}/FavoriteTweet 本文のタイプは次のとおりです。 リクエスト type FavoriteTweetRequest = { variables: { tweet_id: string; // '1867041249938530657' }; queryId: string; // 'lI07N61twFgted2EgXILM7A' }; 本文のタイプは次のとおりです。 応答 type FavoriteTweetResponse = { data: { favorite_tweet: 'Done', } } 見た目は単純で、API 設計に対する RPC のようなアプローチにも似ています。 結論 X の API の例を見て、ホーム タイムライン API 設計の基本的な部分に触れました。私の知る限りでは、いくつかの仮定を立てました。間違って解釈した部分や複雑なニュアンスを見逃した部分もあるかもしれません。しかし、それを念頭に置いても、この概要から役立つ洞察を得て、次回の API 設計セッションに応用できるものがあれば幸いです。 当初、私は Facebook、Reddit、YouTube などから洞察を得て、実戦で実証されたベスト プラクティスとソリューションを収集するために、同様のトップ テクノロジーの Web サイトを調べる計画を立てていました。そのための時間を見つけられるかどうかはわかりません。様子を見ます。しかし、興味深い演習になるかもしれません。 付録: すべてのタイプを 1 か所にまとめる 参考までに、ここではすべてのタイプを一度に追加します。すべてのタイプは、 ファイル。 タイプ/x.ts /** * This file contains the simplified types for X's (Twitter's) home timeline API. * * These types are created for exploratory purposes, to see the current implementation * of the X's API, to see how they fetch Home Feed, how they do a pagination and sorting, * and how they pass the hierarchical entities (posts, media, user info, etc). * * Many properties and types are omitted for simplicity. */ // POST https://x.com/i/api/graphql/{query-id}/HomeTimeline export type TimelineRequest = { queryId: string; // 's6ERr1UxkxxBx4YundNsXw' variables: { count: number; // 20 cursor?: string; // 'DAAACgGBGedb3Vx__9sKAAIZ5g4QENc99AcAAwAAIAIAAA' seenTweetIds: string[]; // ['1867041249938530657', '1867041249938530658'] }; features: Features; }; // POST https://x.com/i/api/graphql/{query-id}/HomeTimeline export type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; responseObjects: { feedbackActions: TimelineAction[]; }; }; }; }; }; // POST https://x.com/i/api/graphql/{query-id}/FavoriteTweet export type FavoriteTweetRequest = { variables: { tweet_id: string; // '1867041249938530657' }; queryId: string; // 'lI07N6OtwFgted2EgXILM7A' }; // POST https://x.com/i/api/graphql/{query-id}/FavoriteTweet export type FavoriteTweetResponse = { data: { favorite_tweet: 'Done', } } // GET https://x.com/i/api/graphql/{query-id}/TweetDetail?variables={"focalTweetId":"1867041249938530657","referrer":"home","controller_data":"DACABBSQ","rankingMode":"Relevance","includePromotedContent":true,"withCommunity":true}&features={"articles_preview_enabled":true} export type TweetDetailResponse = { data: { threaded_conversation_with_injections_v2: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[], }, }, } type Features = { articles_preview_enabled: boolean; view_counts_everywhere_api_enabled: boolean; // ... } type TimelineAction = { key: ActionKey; // '-609233128' value: { feedbackType: 'NotRelevant' | 'DontLike' | 'SeeFewer'; // ... prompt: string; // 'This post isn't relevant' | 'Not interested in this post' | ... confirmation: string; // 'Thanks. You'll see fewer posts like this.' childKeys: ActionKey[]; // ['1192182653', '-1427553257'], ie NotInterested -> SeeFewer feedbackUrl: string; // '/2/timeline/feedback.json?feedback_type=NotRelevant&action_metadata=SRwW6oXZadPHiOczBBaAwPanEwE%3D' hasUndoAction: boolean; icon: string; // 'Frown' }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; }; type TimelineTerminateTimeline = { type: 'TimelineTerminateTimeline', direction: 'Top', } type TimelineCursor = { entryId: string; // 'cursor-top-1867041249938530657' sortIndex: string; // '1867231621095096312' content: { __typename: 'TimelineTimelineCursor'; value: string; // 'DACBCgABGedb4VyaJwuKbIIZ40cX3dYwGgaAAwAEAEEAA' cursorType: 'Top' | 'Bottom'; }; }; type TimelineItem = { entryId: string; // 'tweet-1867041249938530657' sortIndex: string; // '1867231621095096312' content: { __typename: 'TimelineTimelineItem'; itemContent: TimelineTweet; feedbackInfo: { feedbackKeys: ActionKey[]; // ['-1378668161'] }; }; }; type TimelineModule = { entryId: string; // 'conversationthread-1867041249938530657' sortIndex: string; // '1867231621095096312' content: { __typename: 'TimelineTimelineModule'; items: { entryId: string, // 'conversationthread-1867041249938530657-tweet-1867041249938530657' item: TimelineTweet, }[], // Comments to the tweets are also tweets displayType: 'VerticalConversation', }; }; type TimelineTweet = { __typename: 'TimelineTweet'; tweet_results: { result: Tweet; }; }; type Tweet = { __typename: 'Tweet'; core: { user_results: { result: User; }; }; views: { count: string; // '13763' }; legacy: { bookmark_count: number; // 358 created_at: string; // 'Tue Dec 10 17:41:28 +0000 2024' conversation_id_str: string; // '1867041249938530657' display_text_range: number[]; // [0, 58] favorite_count: number; // 151 full_text: string; // "How I'd promote my startup, if I had 0 followers (Part 1)" lang: string; // 'en' quote_count: number; reply_count: number; retweet_count: number; user_id_str: string; // '1867041249938530657' id_str: string; // '1867041249938530657' entities: { media: Media[]; hashtags: Hashtag[]; urls: Url[]; user_mentions: UserMention[]; }; }; }; type User = { __typename: 'User'; id: string; // 'VXNlcjoxNDUxM4ADSG44MTA4NDc4OTc2' rest_id: string; // '1867041249938530657' is_blue_verified: boolean; profile_image_shape: 'Circle'; // ... legacy: { following: boolean; created_at: string; // 'Thu Oct 21 09:30:37 +0000 2021' description: string; // 'I help startup founders double their MRR with outside-the-box marketing cheat sheets' favourites_count: number; // 22195 followers_count: number; // 25658 friends_count: number; location: string; // 'San Francisco' media_count: number; name: string; // 'John Doe' profile_banner_url: string; // 'https://pbs.twimg.com/profile_banners/4863509452891265813/4863509' profile_image_url_https: string; // 'https://pbs.twimg.com/profile_images/4863509452891265813/4863509_normal.jpg' screen_name: string; // 'johndoe' url: string; // 'https://t.co/dgTEddFGDd' verified: boolean; }; }; type Media = { display_url: string; // 'pic.x.com/X7823zS3sNU' expanded_url: string; // 'https://x.com/johndoe/status/1867041249938530657/video/1' ext_alt_text: string; // 'Image of two bridges.' id_str: string; // '1867041249938530657' indices: number[]; // [93, 116] media_key: string; // '13_2866509231399826944' media_url_https: string; // 'https://pbs.twimg.com/profile_images/1867041249938530657/4863509_normal.jpg' source_status_id_str: string; // '1867041249938530657' source_user_id_str: string; // '1867041249938530657' type: string; // 'video' url: string; // 'https://t.co/X78dBgtrsNU' features: { large: { faces: FaceGeometry[] }; medium: { faces: FaceGeometry[] }; small: { faces: FaceGeometry[] }; orig: { faces: FaceGeometry[] }; }; sizes: { large: MediaSize; medium: MediaSize; small: MediaSize; thumb: MediaSize; }; video_info: VideoInfo[]; }; type UserMention = { id_str: string; // '98008038' name: string; // 'Yann LeCun' screen_name: string; // 'ylecun' indices: number[]; // [115, 122] }; type Hashtag = { indices: number[]; // [257, 263] text: string; }; type Url = { display_url: string; // 'google.com' expanded_url: string; // 'http://google.com' url: string; // 'https://t.co/nZh3aF0Aw6' indices: number[]; // [102, 125] }; type VideoInfo = { aspect_ratio: number[]; // [427, 240] duration_millis: number; // 20000 variants: { bitrate?: number; // 288000 content_type?: string; // 'application/x-mpegURL' | 'video/mp4' | ... url: string; // 'https://video.twimg.com/amplify_video/18665094345456w6944/pl/-ItQau_LRWedR-W7.m3u8?tag=14' }; }; type FaceGeometry = { x: number; y: number; h: number; w: number }; type MediaSize = { h: number; w: number; resize: 'fit' | 'crop' }; type ActionKey = string;