paint-brush
Twitter APIを理解して独自のAPIを設計する@trekhleb
194 測定値 新しい歴史

Twitter APIを理解して独自のAPIを設計する

Oleksii Trekhleb22m2024/12/16
Read on Terminal Reader

長すぎる; 読むには

この記事では、X (Twitter) ホームタイムライン (x.com/home) API の設計方法と、複数の課題を解決するためにどのようなアプローチが使用されているかについて説明します。
featured image - Twitter APIを理解して独自のAPIを設計する
Oleksii Trekhleb HackerNoon profile picture
0-item
1-item

システムのAPIを設計する際に、ソフトウェアエンジニアは次のようなさまざまなオプションを検討することが多い。 REST 対 RPC 対 GraphQL (またはその他のハイブリッド アプローチ) を使用して、特定のタスクまたはプロジェクトに最適なものを決定します。


この記事では、 X ( Twitter ) ホーム タイムライン (x.com/home) API の設計方法と、次の課題を解決するためにどのようなアプローチが使用されているかについて説明します。

  • ツイートのリストを取得する方法

  • 並べ替えとページ付けの方法

  • 階層的/リンクされたエンティティ(ツイート、ユーザー、メディア)を返す方法

  • ツイートの詳細を取得する方法

  • ツイートを「いいね」する方法


バックエンド コード自体にはアクセスできないため、バックエンドの実装はブラック ボックスとして扱い、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 のgraphql部分は、X が API に 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 リクエスト本文で同じTimelineRequest.queryIdが使用されていることにも気付くかもしれません。この queryId はおそらくバックエンドで生成され、 main.jsバンドルに埋め込まれ、バックエンドからデータを取得するときに使用されます。X のバックエンドは私たちの場合ブラック ボックスであるため、このqueryIdがどのように使用されるのか正確に理解するのは困難です。ただし、ここでの推測としては、何らかのパフォーマンス最適化 (事前に計算されたクエリ結果の再利用など)、キャッシュ (Apollo 関連など)、デバッグ (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 をあまり調整しなくてもホーム タイムラインでレンダリングできる内容の幅が広がるため、拡張性の観点から興味深いものです。

ページネーション

TimelineRequest.variables.countプロパティは、一度に取得するツイートの数 (ページあたり) を設定します。デフォルトは 20 です。ただし、 TimelineAddEntries.entries配列で 20 を超えるツイートが返されることがあります。たとえば、最初のページの読み込みでは、配列に 37 のエントリが含まれる場合があります。これは、ツイート (29)、固定されたツイート (1)、プロモーションされたツイート (5)、およびページネーション カーソル (2) が含まれているためです。ただし、要求された数が 20 であるのに、通常のツイートが 29 個ある理由はわかりません。


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 のカーソル自体は、次のようになります: DAABCgABGemI6Mk__9sKAAIZ6MSYG9fQGwgAAwAAAAIAAA 。一部の API 設計では、カーソルはリストの最後のエントリの ID または最後に表示されたエントリのタイムスタンプを含む Base64 でエンコードされた文字列である場合があります。たとえば、 eyJpZCI6ICIxMjM0NTY3ODkwIn0= --> {"id": "1234567890"}で、このデータはそれに応じてデータベースを照会するために使用されます。X API の場合、カーソルはカスタム バイナリ シーケンスに Base64 デコードされているように見えますが、意味を理解するにはさらにデコードが必要になる可能性があります (つまり、Protobuf メッセージ定義を介して)。それが.protoエンコーディングであるかどうかは不明であり、また.protoメッセージ定義も不明であるため、バックエンドがカーソル文字列に基づいて次のツイートのバッチを照会する方法を知っていると想定できます。


TimelineResponse.variables.seenTweetIdsパラメータは、無限スクロールの現在アクティブなページからクライアントがすでに見たツイートをサーバーに通知するために使用されます。これにより、サーバーが結果の後続ページに重複したツイートを含めないようにすることができます。

リンクされた/階層化されたエンティティ

ホーム タイムライン (またはホーム フィード) などの API で解決すべき課題の 1 つは、リンクされたエンティティまたは階層的なエンティティ ( tweet → usertweet → mediamedia → 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 → mediatweet → authorなどの依存データのほとんどが最初の呼び出しの応答に埋め込まれていることです (後続のクエリはありません)。


また、 TweetエンティティとのUserおよびMedia接続は正規化されていません (2 つのツイートの著者が同じ場合、そのデータは各ツイート オブジェクトで繰り返されます)。ただし、特定のユーザーのホーム タイムラインの範囲内では、ツイートは多くの著者によって作成され、繰り返しは可能ですがまばらであるため、問題ないと思われます。


特定のユーザーのツイートを取得するUserTweets API (ここでは説明しません) は別の方法で処理するだろうと想定していましたが、どうやらそうではないようです。UserTweets UserTweets同じユーザーのツイートのリストを返し、ツイートごとに同じユーザー データを何度も埋め込みます。興味深いですね。おそらく、このアプローチのシンプルさが、データ サイズのオーバーヘッド (ユーザー データのサイズはかなり小さいと考えられる) を上回っているのでしょう。よくわかりません。


エンティティの関係に関するもう 1 つの観察点は、 MediaエンティティにもUser (作成者) へのリンクがあることです。ただし、 Tweetエンティティのように直接エンティティを埋め込むのではなく、 Media.source_user_id_strプロパティを介してリンクします。


ホーム タイムラインの各「ツイート」の「コメント」(本質的には「ツイート」でもある) はまったく取得されません。ツイート スレッドを表示するには、ユーザーはツイートをクリックして詳細ビューを表示する必要があります。ツイート スレッドは、 TweetDetailエンドポイントを呼び出すことによって取得されます (詳細については、以下の「ツイートの詳細ページ」セクションを参照してください)。


Tweetが持つもう 1 つのエンティティは、 FeedbackActionsです (つまり、「あまり頻繁におすすめしない」または「表示回数を減らす」)。レスポンス オブジェクトでのFeedbackActionsの保存方法は、 UserオブジェクトやMediaオブジェクトの保存方法とは異なります。 UserMediaエンティティはTweetの一部ですが、 FeedbackActions TimelineItem.content.feedbackInfo.feedbackKeys配列に個別に保存され、 ActionKeyを介してリンクされます。これは私にとってはちょっとした驚きでした。なぜなら、どのアクションも再利用できるわけではないようです。1 つのアクションは 1 つの特定のツイートにのみ使用されるようです。したがって、 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}


ここで、ツイートのリストがPOST呼び出しで取得されているのに、各ツイートの詳細はGET呼び出しで取得されているのはなぜか、気になりました。一貫性がないようです。特に、 query-idfeaturesなどの同様のクエリ パラメータは、リクエスト ボディではなく URL で渡されることに留意してください。応答形式も同様で、リスト呼び出しからタイプを再利用しています。その理由はよくわかりません。しかし、ここでも、背景の複雑さを見逃している可能性があります。

簡略化されたレスポンス本文のタイプは次のとおりです。


 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 つは、各ツイートの「コメント」(または会話) が実際には他のツイートであることです ( TimelineModuleタイプを参照)。そのため、ツイート スレッドは、 TimelineTweetエントリのリストを表示することで、ホーム タイムライン フィードと非常によく似ています。これはエレガントに見えます。これは、API 設計に対する汎用的で再利用可能なアプローチの良い例です。

ツイートにいいね

ユーザーがツイートに「いいね!」すると、次のエンドポイントへの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;