探索 X 主页时间线 API 的设计方式
在设计系统 API 时,软件工程师通常会考虑不同的选项,例如 REST、RPC、GraphQL(或其他混合方法),以确定最适合特定任务或项目的选项。
在本文中,我们探讨 **X** (**Twitter**) 主页时间线 (x.com/home) API 的设计方式以及它们使用哪些方法来解决以下挑战:
我们将仅在 API 级别探索这些挑战,将后端实现视为黑盒,因为我们无法访问后端代码本身。

在这里展示确切的请求和响应可能很麻烦,而且很难理解,因为深度嵌套和重复的对象很难阅读。为了更容易看到请求/响应负载结构,我尝试在 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` 指令本身也可能包含不同类型的实体:
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'; }; };
每个页面都包含推文列表以及“顶部”和“底部”光标:

页面数据加载完成后,我们可以从当前页面双向移动,使用“底部”光标获取“上一条/较旧”的推文,或使用“顶部”光标获取“下一条/较新”的推文。我的假设是,使用“顶部”光标获取“下一条”推文发生在两种情况下:当用户仍在阅读当前页面时添加了新推文,或者当用户开始向上滚动提要时(并且没有缓存条目,或者由于性能原因删除了先前的条目)。
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` 的逐步解码:
因此我们可以假设主页时间线中的推文是按时间顺序排序的。
推文操作
每条推文都有一个“操作”菜单。

每条推文的操作都来自后端的“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;