为了账号安全,请及时绑定邮箱和手机立即绑定

探究X(原推特)主页时间线API的设计思路

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

在这篇文章中,我们将探讨X(即Twitter)的首页时间轴(x.com/home)API的设计方式,以及他们如何解决这些挑战。

  • 如何获取推文列表?
  • 如何进行排序和分页
  • 如何返回关联/层级实体(推文、用户、媒体)
  • 如何查看推文详情?
  • 如何给推文点赞?

我们将仅在API层面探讨这些挑战,将后端视为一个黑匣子,因为我们无法接触到后端代码。

X首页时间线示例

由于深度嵌套且重复的对象难以阅读,此处显示确切的请求和响应可能会变得繁琐且难以跟上。为了使请求/响应负载结构更清晰地展现,我尝试用 TypeScript “详细列出” 主页时间线 API 的类型。因此,在请求/响应示例中,我将使用请求和响应的类型而不是实际的 JSON 对象。另外,记住这些类型已经简化,并且省略了许多属性以简洁起见。

您可以在这个 types/x.ts 文件 文件或文章底部的“附录:所有类型汇总”部分找到所有类型。

获取微博列表

接口及请求响应结构: 请求/响应结构

要获取首页时间线的推文列表,我们从发送一个 POST 请求到下面的端点开始。

发送POST请求到 https://x.com/i/api/graphql/{查询ID}/HomeTimeline
(这是一个用于获取首页时间线的技术API端点).

全屏 / 退出

以下是一个简化的请求体
(注:原文末尾无冒号,根据中文书写习惯,此处不加句号,直接省略句末标点。)

    类型 时间线请求 = {
      查询ID(queryId): string; // 's6ERr1UxkxxBx4YundNsXw'
      变量: {
        数量(count): number; // 20
        游标(cursor)?: string; // 'DAAACgGBGedb3Vx__9sKAAIZ5g4QENc99AcAAwAAIAIAAA'
        已见推文ID(seenTweetIds): string[]; // ['1867041249938530657', '1867041249938530659']
      };
      特性类型: {
        文章预览功能启用: boolean;
        everywhere API 查看计数功能启用: boolean;
        // 其他功能...
      };
    };

    类型 特性类型 = {
      文章预览功能启用: boolean;
      everywhere API 查看计数功能启用: boolean;
      // 其他功能...
    }

全屏 退出

以下是一个简化的回复类型(我们将在下面进一步介绍回复子类型):

    type TimelineResponse = {
      data: {
        home: {
          home_timeline_urt: {
            instructions: (时间线添加条目 | 时间线终止时间线)[];
            responseObjects: {
              feedbackActions: TimelineAction[];
            };
          };
        };
      };
    };

    type 时间线添加条目 = {
      type: '时间线添加条目';
      entries: (时间线条目 | 时间线光标 | TimelineModule)[];
    };

    type 时间线条目 = {
      entryId: string; // '推文ID-1867041249938530657'
      sortIndex: string; // '1866561576636152411'
      content: {
        __typename: 'TimelineTimelineItem';
        itemContent: 时间线推文;
        feedbackInfo: {
          feedbackKeys: 操作键[]; // ['-1378668161']
        };
      };
    };

    type 时间线推文 = {
      __typename: 'TimelineTweet';
      tweet_results: {
        result: Tweet;
      };
    };

    type 时间线光标 = {
      entryId: string; // '光标顶部-1867041249938530657'
      sortIndex: string; // '1866961576813152212'
      content: {
        __typename: 'TimelineTimelineCursor';
        value: string; // 'DACBCgABGedb4VyaJwuKbIIZ40cX3dYwGgaAAwAEAEEAA'
        cursorType: '顶部' | '底部';
      };
    };

    type 操作键 = string;

点击全屏 退出全屏

这里有趣的是,获取数据是通过发送“POST”请求的方式进行的,这在REST风格的API中并不常见,但在GraphQL风格的API中则较为常见。此外,URL中的graphql部分显示X使用的是GraphQL风格的API。

在这里我使用了“味道”这个词,因为请求体本身看起来并不纯粹像一个GraphQL查询,我们可以在那里描述所需的响应结构,列出我们想要获取的所有属性:

# 一个纯GraphQL请求结构示例,此结构在X API中并未实际使用。
{
  推文 {
    id
    description
    创建时间
    medias {
      kind
      url
      等等
    }
    作者 {
      id
      name
      等等
    }
    等等
  }
}

全屏 退出全屏

这里假设,主页时间线 API 并不是一个纯粹的 GraphQL API,而是一个 几种方法的结合。通过 POST 请求传递参数就像这样,更像是“功能性的”RPC 调用。然而,同时,它似乎在 HomeTimeline 端点处理程序/控制器内部使用了 GraphQL 特性。这种混合可能是由于遗留代码或者正在进行的迁移导致的。不过这只是我的猜测。

你可能还会注意到,相同的 TimelineRequest.queryId 既出现在 API 的 URL 中,也在 API 请求体中出现。这个 queryId 可能是后端生成的,然后嵌入到 main.js 文件中,在从后端获取数据时使用。由于 X 的后端对我们来说像一个黑盒子,所以对于这个 queryId 的具体用途很难确切理解。不过,它可能被用来做一些性能优化(例如,重用一些预先计算好的结果?)、缓存(例如,Apollo 相关?)、调试(通过 queryId 连接日志?)或者是为了跟踪和追踪的目的。

有趣的是,TimelineResponse 并不包含推文的列表,而是一系列的指示,例如 "将一条推文加入时间线"(参见 TimelineAddEntries 类型的指令),或者 "结束时间线"(参见 TimelineTerminateTimeline 类型的指令)。

TimelineAddEntries 指令本身可能包含不同类型的实体,

  • 推文(推文) — 参见 TimelineItem
  • 光标 — 参见 TimelineCursor
  • 对话/评论/讨论串 — 参见 TimelineModule 表示...
    type TimelineResponse = {
      data: {
        home: {
          home_timeline_urt: {
            instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // 例如
            // ...
          };
        };
      };
    };

    type TimelineAddEntries = {
      type: 'TimelineAddEntries';
      entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // 如下所示
    };

切换到全屏模式 退出全屏

这从可扩展性的角度来看很有趣,因为它可以在无需大幅修改API的情况下,在主页时间线中渲染更多类型的内容。

分页功能

TimelineRequest.variables.count 属性设置每页希望获取的推文数量。默认值为20。然而,在TimelineAddEntries.entries数组中,实际返回的推文数量可能超过20条。例如,在首次加载时,数组可能包含37个项目,其中包括29条普通推文、1条置顶推文、5条推广推文和2个分页游标。我不明白为什么在请求数量为20的情况下,会有29条普通推文。

这用于实现基于游标的分页功能,其中TimelineRequest.variables.cursor起到了游标的作用。

"游标分页 最常用于实时数据,因为新记录频繁添加,而且在读取数据时,你通常首先看到的是最新结果。它避免了跳过项目和重复显示同一个项目。在游标分页中,会使用一个固定的指针(或游标)来跟踪从数据集中获取下一个项目的具体位置。" 更多相关信息,请查看Offset 分页和游标分页的区别这一讨论。

在首次获取推文列表时,TimelineRequest.variables.cursor 是空的,因为我们想要从默认(预先设定的)个性化推文流中获取顶级推文,以达到我们的需求。

然而,在响应中,除了推文数据,后端还包括游标记录。响应类型层次结构如下:TimelineResponse → TimelineAddEntries → TimelineCursor

    type TimelineResponse = {
      data: {
        home: {
          home_timeline_urt: {
            instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // <-- 如下
            // ...
          };
        };
      };
    };

    type TimelineAddEntries = {
      type: 'TimelineAddEntries';
      entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // <-- 如下(推文和数据指针)
    };

    type TimelineCursor = {
      entryId: string;
      sortIndex: string;
      content: {
        __typename: 'TimelineTimelineCursor';
        value: string; // 'DACBCgABGedb4VyaJwuKbIIZ40cX3dYwGgaAAwAEAEEAA' <-- 例如
        cursorType: 'Top' | 'Bottom';
      };
    };

全屏模式, 退出全屏

每页都有推文列表及顶端和底端指针。

光标(.Cursors):

页面数据加载后,我们可以在当前页面的两个方向上操作,使用“底部”游标来获取较旧的推文,或者使用“顶部”游标来获取较新的推文。我的假设是,使用“顶部”游标获取较新推文的情况有两种:当用户仍在阅读当前页面时,新的推文被添加了,或者当用户开始向上滚动动态(或者是没有缓存条目,或者为了性能原因删除了之前的条目)。

X 的游标的外观可能像这样:DAABCgABGemI6Mk__9sKAAIZ6MSYG9fQGwgAAwAAAAIAAA。在某些 API 设计中,游标可能是包含列表最后一个条目 ID 或时间戳的 Base64 编码字符串。比如:eyJpZCI6ICIxMjM0NTY3ODkwIn0= --> {"id": "1234567890"},然后用这个信息查询数据库。在 X API 的情况下,游标似乎是被 Base64 解码成某种自定义的二进制格式,可能还需要进一步解析才能理解,例如使用 Protobuf 消息定义。由于我们不清楚这是不是 .proto 编码格式,也不知道 .proto 消息定义,所以我们可以假设后台能根据游标字符串获取下一批推文。

TimelineResponse.variables.seenTweetIds 参数用于通知服务器客户端已经看到哪些当前页面无限滚动中的推文。这有助于确保服务器在后续页面中避免重复推送推文。因此,这最有可能帮助确保服务器不会在后续结果页面中包含重复的推文。

关联或层级实体

在像主页时间线(或主页动态流)这样的 API 中,需要解决的一个问题是如何返回链接或分层的实体(比如 推文 → 用户推文 → 媒体媒体 → 作者 等):

  • 我们是否应该先返回推文列表,然后根据需要通过一系列单独的请求来获取相关实体(如用户详情)?
  • 或者我们应该一次性返回所有数据,虽然这会增加首次加载的时间和数据量,但可以节省所有后续调用的时间?

  • 在这种情况下,我们是否需要对数据进行规范化以减少负载大小(即,当同一个用户是多条推文的作者时,我们希望避免在每个推文中重复用户信息)?
  • 或者是否可以结合上述两种方法?

注:Markdown格式已保留,符合要求。(此注释已根据专家建议删除)

我们来看看X是怎么做的。

之前在 TimelineTweet 类型中,用到了 Tweet 子类型。我们来看看它长什么样:

    export type TimelineResponse = {
      data: {
        home: {
          home_timeline_urt: {
            instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; 
            // ...
          };
        };
      };
    };

    type TimelineAddEntries = {
      type: 'TimelineAddEntries';
      entries: (TimelineItem | TimelineCursor | TimelineModule)[]; 
    };

    type TimelineItem = {
      entryId: string;
      sortIndex: string;
      content: {
        __typename: 'TimelineTimelineItem';
        itemContent: TimelineTweet; 
        // ...
      };
    };

    type TimelineTweet = {
      __typename: 'TimelineTweet';
      tweet_results: {
        result: Tweet; 
      };
    };

    // 推文实体
    type Tweet = {
      __typename: 'Tweet';
      core: {
        user_results: {
          result: User; 
        };
      };
      legacy: {
        full_text: string;
        // ...
        entities: { 
          media: Media[];
          hashtags: Hashtag[];
          urls: Url[];
          user_mentions: UserMention[];
        };
      };
    };

    // 用户实体
    type User = {
      __typename: 'User';
      id: string; // 'VXNlcjoxNDUxM4ADSG44MTA4NDc4OTc2'
      // ...
      legacy: {
        location: string; // 'San Francisco'
        name: string; //  'John Doe'
        // ...
      };
    };

    // 媒体实体
    type Media = {
      // ...
      source_user_id_str: string; // '1867041249938530657' 
      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 在第一次调用时就已经被包含在响应中(不需要额外的查询)。

此外,UserMediaTweet 实体之间的关联没有规范化(如果两条推文具有相同的作者,相同作者的数据会在每个推文对象中重复)。但是看起来这样也没问题,因为在特定用户的主页时间线范围内,推文会由多位作者发布,重复是可能的,但不会太多。

我的假设是,负责获取特定用户推文的UserTweets API(我们这里不讨论),会以不同的方式处理这些推文,但实际上并非如此。UserTweets返回的是同一个用户的推文列表,并且每条推文都会重复嵌入相同的用户数据。这很有趣,或许这种简单的方法胜过了数据量的增加,可能认为用户数据量相对较小。我还不确定。

另一个关于这些实体间关系的观察是,Media 实体也与 User(即作者)有关联。但它不是像 Tweet 实体那样直接嵌入 User 实体,而是通过 Media.source_user_id_str 属性来链接。

每个“推文”的“评论”(也就是“回复”,因为这里的“评论”也是“推文”的一部分)都不会被获取。用户需要点击推文才能看到详细的推文串。通过点击TweetDetail端点(详情请参阅下文的“推文详情页面”部分)来获取推文串。

每个 Tweet 还有一个实体是 FeedbackActions(即“减少推荐频率”或“减少显示”)。FeedbackActions 在响应对象中的存储方式不同于 UserMedia 对象的存储方式。这让我有些意外,因为似乎并没有任何动作是可以重复使用的。虽然 UserMedia 实体是 Tweet 的一部分,但 FeedbackActions 却单独存储在 TimelineItem.content.feedbackInfo.feedbackKeys 数组中,通过 ActionKey 连接。每个动作似乎只适用于一条特定的推文。因此,FeedbackActions 可能可以像 Media 实体一样嵌入到每条推文中。但我可能忽略了某些复杂性(比如每个动作可能包含子动作)。

更多关于这些操作的详情请参见下方的“推文操作”部分。

排序:

时间线条目的排序由后端通过 sortIndex 属性来定义。

type TimelineCursor = {
  entryId: string; // 条目ID
  sortIndex: string; // 排序索引: '1866961576813152212' 如:这里
  content: {
    __typename: 'TimelineTimelineCursor';
    value: string;
    cursorType: 'Top' | 'Bottom';
  };
};

type 时间线项 TimelineItem = {
  entryId: string; // 条目ID
  sortIndex: string; // 排序索引: '1866561576636152411' 如:这里
  content: {
    __typename: 'TimelineTimelineItem';
    itemContent: TimelineTweet;
    反馈信息: {
      feedbackKeys: ActionKey[];
    };
  };
};

type 时间线模块 TimelineModule = {
  entryId: string; // 条目ID
  sortIndex: string; // 排序索引: '73343543020642838441' 如:这里
  content: {
    __typename: 'TimelineTimelineModule';
    items: {
      entryId: string,
      item: TimelineTweet,
    }[],
    显示类型: 'VerticalConversation',
  };
};

进入全屏模式。退出全屏模式。

sortIndex 本身可能看起来像这样 '1867231621095096312'。它可能直接来自于或根据一个 Snowflake ID 生成之类的。

实际上,你看到的响应中的大多数ID(推特ID)遵循“Snowflake ID”格式,看起来像 '1867231621095096312'(类似于这个数字)

如果它被用来对类似推文这样的实体进行排序,系统则利用Snowflake ID固有的时间顺序特性。具有较高sortIndex值(即较新时间戳)的推文或对象会出现在feed的更高位置,而具有较低sortIndex值(即较旧时间戳)的则出现在feed的较低位置。

下面就是一步步解析雪花ID(即我们这里的sortIndex):1867231621095096312

  • 提取 时间戳

  • 时间戳是通过将Snowflake ID右移22位(移除低22位的数据中心、工作机ID和序列号)得到的: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[]; // <-- 这里
        };
      };
    };
  };
};

type TimelineItem = {
  entryId: string;
  sortIndex: string;
  content: {
    __typename: 'TimelineTimelineItem';
    itemContent: TimelineTweet;
    feedbackInfo: {
      feedbackKeys: ActionKey[]; // ['-1378668161'] <-- 这里
    };
  };
};

type TimelineAction = {
  key: ActionKey; // '-609233128'
  value: {
    feedbackType: '不相关' | '不感兴趣' | '不想看到更多'; // ...
    prompt: string; // '这个帖子不相关' | '对这篇帖子不感兴趣' | ...
    confirmation: string; // '谢谢。你将看到更少类似的内容。'
    childKeys: ActionKey[]; // ['1192182653', '-1427553257'],即 不感兴趣 -> 不想看到更多
    feedbackUrl: string; // '/2/timeline/feedback.json?feedback_type=NotRelevant&action_metadata=SRwW6oXZadPHiOczBBaAwPanEwE%3D'
    hasUndoAction: boolean;
    icon: string; // '皱眉'
  };
};

切换到全屏 退出全屏

这里有意思的是,这些看似扁平的动作实际上组成了一个树结构(也可能是图,不过我没有具体检查过),因为每个动作可能有子动作(参见 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: (时间线添加条目 | 时间线终止指令)[],
        },
      },
    }

    type 时间线添加条目 = {
      type: "时间线添加条目";
      entries: (时间线条目 | 时间线游标 | 时间线模块)[];
    };

    type 时间线终止指令 = {
      type: "时间线终止指令",
      direction: "Top",
    };

    type 时间线模块 = {
      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; // 微博ID示例:'1867041249938530657'
      };
      queryId: string; // 查询ID示例:'lI07N61twFgted2EgXILM7A'
    };

全屏模式, 退出全屏

这里是回复主体类型如下所示。

    type FavoriteTweetResponse = {
      data: {
        favorite_tweet: '已收藏',
      }
    }

全屏显示 退出全屏

看起来挺直接,也类似 RPC 风格的 API 设计。

最后

我们通过查看X的API示例,已经讨论了一些基本的家庭时间线API的设计。过程中我根据我的理解做了一些假设。我认为我可能误解了一些内容,也可能忽略了某些细微之处。即便如此,希望你从这次高层次的概览中获得了有用的见解,这些见解你可以在下次API设计中应用。

最初,我计划浏览一些类似的技术网站,从Facebook、Reddit、YouTube等获取一些启示,并收集实战中验证的最佳实践和解决方案。不确定是否有时间去做这件事。看看吧。但这可能会是一个有趣的尝试。

附录:一览无余

为了方便参考,我在这里一次添加了所有类型。你也可以在 types/x.ts 文件里找到这些类型。

    /**

* 下文包含了X(推特)主页时间线API的简化类型。
     *

* 这些类型是为了探索性目的而创建的,用以查看X的API当前的实现情况,了解它们如何获取主页时间线,如何实现分页和排序,以及如何传递层级实体(如帖子、媒体、用户信息等)。
     *

* 省略了许多属性和类型以简化内容。
     */

    // 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: {
          时间轴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: '不相关' | '不喜欢' | '少看此类帖子'; // ...
        prompt: string; // '这个帖子不相关' | '我不感兴趣' | ...
        confirmation: string; // '谢谢。您将看到更少此类帖子。'
        childKeys: ActionKey[]; // ['1192182653', '-1427553257'], 即 不感兴趣 -> 看到更少
        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,
        }[], // 评论也是帖子
        displayType: '垂直对话',
      };
    };

    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
        发布时间: string; // '2024年12月10日 17:41:28 UTC'
        conversation_id_str: string; // '1867041249938530657'
        文本显示范围: number[]; // [0, 58]
        favorite_count: number; // 151
        full_text: string; // "如何在没有关注者的情况下推广我的创业公司(第1部分)"
        lang: string; // 'en'
        quote_count: number;
        reply_count: number;
        retweet_count: number;
        user_id_str: string; // '1867041249938530657'
        id_str: string; // '1867041249938530657'
        实体: {
          media: Media[];
          话题标签: Hashtag[];
          urls: Url[];
          用户提及: UserMention[];
        };
      };
    };

    type User = {
      __typename: 'User';
      id: string; // 'VXNlcjoxNDUxM4ADSG44MTA4NDc4OTc2'
      rest_id: string; // '1867041249938530657'
      is_blue_verified: boolean;
      profile_image_shape: 'Circle'; // ...
      legacy: {
        following: boolean;
        发布时间: string; // '2021年10月21日 09:30:37 UTC'
        description: string; // '我帮助创业公司创始人提高他们的MRR'
        favorites_count: number; // 22195
        followers_count: number; // 25658
        friends_count: number;
        location: string; // '旧金山'
        media_count: number;
        name: string; // '约翰·多伊'
        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; // '两座桥梁的图片'
      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 = {
      宽高比: 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;

点击此处进入全屏模式,点击此处退出全屏模式

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消