システムのAPIを設計する際に、ソフトウェアエンジニアは次のようなさまざまなオプションを検討することが多い。
この記事では、 X ( Twitter ) ホーム タイムライン (x.com/home) API の設計方法と、次の課題を解決するためにどのようなアプローチが使用されているかについて説明します。
ツイートのリストを取得する方法
並べ替えとページ付けの方法
階層的/リンクされたエンティティ(ツイート、ユーザー、メディア)を返す方法
ツイートの詳細を取得する方法
ツイートを「いいね」する方法
バックエンド コード自体にはアクセスできないため、バックエンドの実装はブラック ボックスとして扱い、API レベルでのみこれらの課題を検討します。
ここで正確なリクエストとレスポンスを示すのは、深くネストされた反復的なオブジェクトが読みにくいため、面倒で理解しにくいかもしれません。リクエスト/レスポンスのペイロード構造をわかりやすくするために、ホーム タイムライン API を TypeScript で「入力」してみました。そのため、リクエスト/レスポンスの例では、実際の JSON オブジェクトではなく、リクエスト タイプとレスポンスタイプを使用します。また、簡潔にするためにタイプが簡略化され、多くのプロパティが省略されていることに注意してください。
すべてのタイプが見つかります
ホーム タイムラインのツイートのリストを取得するには、次のエンドポイントへの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 フレーバーを使用していることを示しています。
ここで「フレーバー」という言葉を使うのは、リクエスト本体自体が純粋な
# 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 → 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
などの依存データのほとんどが最初の呼び出しの応答に埋め込まれていることです (後続のクエリはありません)。
また、 Tweet
エンティティとのUser
およびMedia
接続は正規化されていません (2 つのツイートの著者が同じ場合、そのデータは各ツイート オブジェクトで繰り返されます)。ただし、特定のユーザーのホーム タイムラインの範囲内では、ツイートは多くの著者によって作成され、繰り返しは可能ですがまばらであるため、問題ないと思われます。
特定のユーザーのツイートを取得するUserTweets
API (ここでは説明しません) は別の方法で処理するだろうと想定していましたが、どうやらそうではないようです。UserTweets UserTweets
同じユーザーのツイートのリストを返し、ツイートごとに同じユーザー データを何度も埋め込みます。興味深いですね。おそらく、このアプローチのシンプルさが、データ サイズのオーバーヘッド (ユーザー データのサイズはかなり小さいと考えられる) を上回っているのでしょう。よくわかりません。
エンティティの関係に関するもう 1 つの観察点は、 Media
エンティティにもUser
(作成者) へのリンクがあることです。ただし、 Tweet
エンティティのように直接エンティティを埋め込むのではなく、 Media.source_user_id_str
プロパティを介してリンクします。
ホーム タイムラインの各「ツイート」の「コメント」(本質的には「ツイート」でもある) はまったく取得されません。ツイート スレッドを表示するには、ユーザーはツイートをクリックして詳細ビューを表示する必要があります。ツイート スレッドは、 TweetDetail
エンドポイントを呼び出すことによって取得されます (詳細については、以下の「ツイートの詳細ページ」セクションを参照してください)。
各Tweet
が持つもう 1 つのエンティティは、 FeedbackActions
です (つまり、「あまり頻繁におすすめしない」または「表示回数を減らす」)。レスポンス オブジェクトでのFeedbackActions
の保存方法は、 User
オブジェクトやMedia
オブジェクトの保存方法とは異なります。 User
とMedia
エンティティは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) のほとんどは「Snowflake ID」規則に従っており、 '1867231621095096312'
のようになります。
これをツイートなどのエンティティの並べ替えに使用すると、システムは Snowflake ID の固有の時系列並べ替えを活用します。sortIndex 値が高いツイートまたはオブジェクト (タイムスタンプが新しいもの) はフィード内の上位に表示され、値が低いツイートまたはオブジェクト (タイムスタンプが古いもの) はフィード内の下位に表示されます。
以下は、Snowflake ID (この場合はsortIndex
) 1867231621095096312
のデコードを段階的に説明したものです。
1867231621095096312 → 445182709954
445182709954 + 1288834974657 → 1734017684611ms
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-id
、 features
などの同様のクエリ パラメータは、リクエスト ボディではなく 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 サイトを調べる計画を立てていました。そのための時間を見つけられるかどうかはわかりません。様子を見ます。しかし、興味深い演習になるかもしれません。
参考までに、ここではすべてのタイプを一度に追加します。すべてのタイプは、
/** * 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;