探索 X 主页时间线 API 的设计方式

在设计系统 API 时,软件工程师通常会考虑不同的选项,例如 REST、RPC、GraphQL(或其他混合方法),以确定最适合特定任务或项目的选项。

在本文中,我们探讨 **X** (**Twitter**) 主页时间线 (x.com/home) API 的设计方式以及它们使用哪些方法来解决以下挑战:

  • 如何获取推文列表
  • 如何进行排序和分页
  • 如何返回分层/链接实体(推文、用户、媒体)
  • 如何获取推文详细信息
  • 如何“喜欢”一条推文
  • 我们将仅在 API 级别探索这些挑战,将后端实现视为黑盒,因为我们无法访问后端代码本身。

    X home timeline Example

    在这里展示确切的请求和响应可能很麻烦,而且很难理解,因为深度嵌套和重复的对象很难阅读。为了更容易看到请求/响应负载结构,我尝试在 TypeScript 中“键入”主页时间线 API。因此,当涉及到请求/响应示例时,我将使用请求和响应**类型**而不是实际的 JSON 对象。另外,请记住,为了简洁起见,类型已简化,并且省略了许多属性。

    您可以在 types/x.ts 文件中或在本文底部的“附录:所有类型都在一个地方”部分找到所有类型。

    获取推文列表

    端点和请求/响应结构

    获取主页时间线的推文列表首先向以下端点发出 `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;

    这里值得注意的是,“获取”数据是通过“POSTing”完成的,这对于 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 功能可能在端点处理程序/控制器后面的后端某处使用。这种混合也可能是由遗留代码或某种正在进行的迁移造成的。但同样,这些只是我的猜测。

    您可能还会注意到,API URL 和 API 请求主体中使用了相同的“TimelineRequest.queryId”。此 queryId 很可能在后端生成,然后嵌入到“main.js”包中,然后在从后端获取数据时使用。我很难理解这个“queryId”的具体用法,因为在我们的例子中,X 的后端是一个黑匣子。但是,再次,这里的猜测可能是,它可能需要进行某种性能优化(重新使用一些预先计算的查询结果?)、缓存(与 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';
      };
    };

    每个页面都包含推文列表以及“顶部”和“底部”光标:

    Cursors

    页面数据加载完成后,我们可以从当前页面双向移动,使用“底部”光标获取“上一条/较旧”的推文,或使用“顶部”光标获取“下一条/较新”的推文。我的假设是,使用“顶部”光标获取“下一条”推文发生在两种情况下:当用户仍在阅读当前页面时添加了新推文,或者当用户开始向上滚动提要时(并且没有缓存条目,或者由于性能原因删除了先前的条目)。

    X 的光标本身可能看起来像这样:`DAABCgABGemI6Mk__9sKAAIZ6MSYG9fQGwgAAwAAAAIAAA`。在某些 API 设计中,光标可能是 Base64 编码的字符串,其中包含列表中最后一个条目的 ID,或最后看到的条目的时间戳。例如:`eyJpZCI6ICIxMjM0NTY3ODkwIn0= --> {"id": "1234567890"}`,然后,这些数据用于相应地查询数据库。在 X API 的情况下,光标似乎正在被 Base64 解码为一些自定义二进制序列,可能需要进一步解码才能从中获取任何含义(即通过 Protobuf 消息定义)。由于我们不知道它是否是 `.proto` 编码,也不知道 `.proto` 消息定义,所以我们只能假设后端知道如何根据光标字符串查询下一批推文。

    `TimelineResponse.variables.seenTweetIds` 参数用于通知服务器客户端已经看过当前活动页面中的哪些推文。这很可能有助于确保服务器不会在后续结果页面中包含重复的推文。

    链接/分层实体

    主页时间线(或主页动态)等 API 需要解决的挑战之一是弄清楚如何返回链接或分层实体(即“推文→用户”、“推文→媒体”、“媒体→作者”等):

  • 我们是否应该首先返回推文列表,然后根据需要在一堆单独的查询中获取相关实体(如用户详细信息)?
  • 或者我们应该一次返回所有数据,增加第一次加载的时间和大小,但节省所有后续调用的时间?在这种情况下,我们是否需要规范化数据以减少有效负载大小(即当同一个用户是多条推文的作者,并且我们希望避免在每个推文实体中一遍又一遍地重复用户数据)?
  • 或者应该是上述方法的组合?
  • 让我们看看 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”)在第一次调用时嵌入到响应中(没有后续查询)。

    此外,与“推文”实体的“用户”和“媒体”连接未标准化(如果两条推文的作者相同,则其数据将在每个推文对象中重复)。但似乎应该没问题,因为在特定用户的主页时间线范围内,推文将由许多作者撰写,并且可能出现重复,但很少。

    我的假设是,负责获取推文的 `UserTweets` API(我们这里不介绍)将以不同的方式处理它,但显然事实并非如此。`UserTweets` 返回同一用户的推文列表,并为每条推文反复嵌入相同的用户数据。这很有趣。也许这种方法的简单性胜过一些数据大小开销(也许用户数据被认为非常小)。我不确定。

    关于实体关系的另一个观察是,“媒体”实体也与“用户”(作者)有联系。但它不像“推特”实体那样通过直接实体嵌入来实现,而是通过“Media.source_user_id_str”属性进行链接。

    主页时间线中每条“推文”的“评论”(本质上也是“推文”)根本不会被提取。要查看推文线程,用户必须单击推文才能查看其详细视图。将通过调用“TweetDetail”端点来提取推文线程(有关更多信息,请参阅下面的“推文详细信息页面”部分)。

    每条“推文”都具有的另一个实体是“反馈操作”(即“减少推荐次数”或“查看次数更少”)。“反馈操作”在响应对象中的存储方式与“用户”和“媒体”对象的存储方式不同。虽然“用户”和“媒体”实体是“推文”的一部分,但“反馈操作”单独存储在“TimelineItem.content.feedbackInfo.feedbackKeys”数组中,并通过“ActionKey”链接。这对我来说有点意外,因为似乎任何操作都不能重复使用。看起来一个操作只用于一条特定的推文。因此,似乎“反馈操作”可以像“媒体”实体一样嵌入到每条推文中。但我可能忽略了这里的一些隐藏的复杂性(例如每个操作都可以有子操作)。

    有关这些操作的更多详细信息,请参阅下面的“推文操作”部分。

    排序

    时间线条目的排序顺序由后端通过“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''。它可能直接对应于或源自 Snowflake ID。

    实际上,您在响应中看到的大多数 ID(推文 ID)都遵循“Snowflake ID”约定,看起来像“1867231621095096312”。

    如果将其用于对推文等实体进行排序,系统将利用 Snowflake ID 固有的时间顺序排序。sortIndex 值较高的推文或对象(时间戳较近)在 feed 中显示的位置较高,而值较低的推文或对象(时间戳较旧)在 feed 中显示的位置较低。

    以下是 Snowflake ID(在我们的例子中为 `sortIndex`)`1867231621095096312` 的逐步解码:

  • 提取时间戳:时间戳是通过将 Snowflake ID 右移 22 位得出的(删除数据中心、工作器 ID 和序列的低 22 位):1867231621095096312 → 445182709954
  • 添加 Twitter 的纪元:将 Twitter 的自定义纪元 (1288834974657) 添加到此时间戳,即可得到以毫秒为单位的 UNIX 时间戳:445182709954 + 1288834974657 → 1734017684611ms
  • 转换为人类可读的日期:将 UNIX 时间戳转换为 UTC 日期时间可得出:1734017684611ms → 2024-12-12 15:34:44.611 (UTC)
  • 因此我们可以假设主页时间线中的推文是按时间顺序排序的。

    推文操作

    每条推文都有一个“操作”菜单。

    Actions

    每条推文的操作都来自后端的“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'], i.e. 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',
      };
    };

    该响应(在类型上)与列表响应非常相似,因此我们不会在这里讨论太长时间。

    一个有趣的细微差别是,每条推文的“评论”(或对话)实际上是其他推文(参见“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',
      }
    }

    看起来很简单,也类似于 RPC 类的 API 设计方法。

    结论

    通过查看 X 的 API 示例,我们了解了主页时间线 API 设计的一些基本部分。在此过程中,我尽我所知做了一些假设。我认为有些事情我可能解释错误,我可能错过了一些复杂的细微差别。但即使考虑到这一点,我希望您从这个高级概述中获得一些有用的见解,您可以在下一个 API 设计会话中应用这些见解。

    最初,我计划浏览类似的顶级科技网站,从 Facebook、Reddit、YouTube 等网站获取一些见解,并收集经过实践检验的最佳实践和解决方案。我不确定我是否有时间这样做。拭目以待。但这可能是一个有趣的练习。

    附录:所有类型都集中在一处

    为了方便参考,我在这里一次性添加了所有类型。您也可以在 types/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'], i.e. 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;