Cuando se trata de diseñar la API del sistema, los ingenieros de software a menudo consideran diferentes opciones como
En este artículo, exploramos cómo está diseñada la API de la línea de tiempo de inicio de X ( Twitter ) (x.com/home) y qué enfoques utilizan para resolver los siguientes desafíos:
Cómo obtener la lista de tweets
Cómo hacer una ordenación y paginación
Cómo devolver las entidades jerárquicas/vinculadas (tweets, usuarios, medios)
Cómo obtener detalles de un tweet
Cómo darle "Me gusta" a un tweet
Solo exploraremos estos desafíos a nivel de API, tratando la implementación del backend como una caja negra, ya que no tenemos acceso al código del backend en sí.
Mostrar las solicitudes y respuestas exactas aquí puede resultar complicado y difícil de seguir, ya que los objetos profundamente anidados y repetitivos son difíciles de leer. Para que sea más fácil ver la estructura de carga útil de la solicitud/respuesta, he intentado "escribir" la API de la línea de tiempo de inicio en TypeScript. Por lo tanto, cuando se trata de los ejemplos de solicitud/respuesta, utilizaré los tipos de solicitud y respuesta en lugar de los objetos JSON reales. Además, recuerda que los tipos están simplificados y se omiten muchas propiedades para abreviar.
Puedes encontrar todo tipo en
La obtención de la lista de tweets para la línea de tiempo de inicio comienza con la solicitud POST
al siguiente punto final:
POST https://x.com/i/api/graphql/{query-id}/HomeTimeline
A continuación se muestra un tipo de cuerpo de solicitud simplificado:
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; // ... }
Y aquí hay un tipo de cuerpo de respuesta simplificado (profundizaremos en los subtipos de respuesta a continuación):
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;
Es interesante notar aquí que la "obtención" de los datos se realiza mediante "POSTing", lo cual no es común para la API de tipo REST, pero sí para una API de tipo GraphQL. Además, la parte graphql
de la URL indica que X está usando la versión GraphQL para su API.
Estoy usando la palabra "sabor" aquí porque el cuerpo de la solicitud en sí no parece un sabor puro.
# 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 # ... } # ... } }
En este caso, se supone que la API de la línea de tiempo de inicio no es una API GraphQL pura, sino una combinación de varios enfoques . Pasar los parámetros en una solicitud POST como esta parece más cercano a la llamada RPC "funcional". Pero, al mismo tiempo, parece que las características de GraphQL podrían usarse en algún lugar del backend detrás del controlador/manejador del punto final de HomeTimeline . Una combinación como esta también podría deberse a un código heredado o algún tipo de migración en curso. Pero, de nuevo, estas son solo mis especulaciones.
También puedes notar que el mismo TimelineRequest.queryId
se usa en la URL de la API, así como en el cuerpo de la solicitud de la API. Este queryId probablemente se genera en el backend, luego se integra en el paquete main.js
y luego se usa al obtener los datos del backend. Es difícil para mí entender cómo se usa exactamente este queryId
, ya que el backend de X es una caja negra en nuestro caso. Pero, nuevamente, la especulación aquí podría ser que podría ser necesario para algún tipo de optimización del rendimiento (¿reutilizar algunos resultados de consulta precalculados?), almacenamiento en caché (¿relacionado con Apollo?), depuración (¿unir registros por queryId?) o propósitos de seguimiento/rastreo.
También es interesante notar que TimelineResponse
no contiene una lista de tweets, sino una lista de instrucciones , como "agregar un tweet a la línea de tiempo" (ver el tipo TimelineAddEntries
) o "terminar la línea de tiempo" (ver el tipo TimelineTerminateTimeline
).
La instrucción TimelineAddEntries
también puede contener diferentes tipos de entidades:
TimelineItem
TimelineCursor
TimelineModule
type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // <-- Here // ... }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // <-- Here };
Esto es interesante desde el punto de vista de la extensibilidad, ya que permite una variedad más amplia de lo que se puede representar en la línea de tiempo de inicio sin modificar demasiado la API.
La propiedad TimelineRequest.variables.count
establece la cantidad de tweets que queremos recuperar a la vez (por página). El valor predeterminado es 20. Sin embargo, se pueden devolver más de 20 tweets en la matriz TimelineAddEntries.entries
. Por ejemplo, la matriz puede contener 37 entradas para la primera carga de la página, porque incluye tweets (29), tweets fijados (1), tweets promocionados (5) y cursores de paginación (2). Sin embargo, no estoy seguro de por qué hay 29 tweets normales con el recuento solicitado de 20.
TimelineRequest.variables.cursor
es responsable de la paginación basada en el cursor.
" La paginación con cursor se utiliza con mayor frecuencia para datos en tiempo real debido a la frecuencia con la que se agregan nuevos registros y porque al leer datos, a menudo se ven primero los resultados más recientes. Elimina la posibilidad de omitir elementos y mostrar el mismo elemento más de una vez. En la paginación basada en cursor, se utiliza un puntero constante (o cursor) para realizar un seguimiento de dónde se deben obtener los siguientes elementos en el conjunto de datos". Consulte la
Al obtener la lista de tweets por primera vez, el TimelineRequest.variables.cursor
está vacío, ya que queremos obtener los tweets principales de la lista predeterminada (probablemente precalculada) de tweets personalizados.
Sin embargo, en la respuesta, junto con los datos del tweet, el backend también devuelve las entradas del cursor. Aquí está la jerarquía del tipo de respuesta: 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'; }; };
Cada página contiene la lista de tweets junto con los cursores "superior" e "inferior":
Una vez cargados los datos de la página, podemos ir desde la página actual en ambas direcciones y buscar los tweets "anteriores/más antiguos" usando el cursor "inferior" o los tweets "próximos/más nuevos" usando el cursor "superior". Supongo que la búsqueda de los tweets "próximos" usando el cursor "superior" ocurre en dos casos: cuando los tweets nuevos se agregaron mientras el usuario todavía estaba leyendo la página actual o cuando el usuario comenzó a desplazarse hacia arriba por el feed (y no hay entradas en caché o si las entradas anteriores se eliminaron por razones de rendimiento).
El cursor de la X en sí podría verse así: DAABCgABGemI6Mk__9sKAAIZ6MSYG9fQGwgAAwAAAAIAAA
. En algunos diseños de API, el cursor puede ser una cadena codificada en Base64 que contiene el id de la última entrada en la lista, o la marca de tiempo de la última entrada vista. Por ejemplo: eyJpZCI6ICIxMjM0NTY3ODkwIn0= --> {"id": "1234567890"}
, y luego, estos datos se utilizan para consultar la base de datos en consecuencia. En el caso de la API X, parece que el cursor está siendo decodificado en Base64 en alguna secuencia binaria personalizada que podría requerir una decodificación adicional para obtener algún significado de ella (es decir, a través de las definiciones de mensajes de Protobuf). Como no sabemos si es una codificación .proto
y tampoco conocemos la definición del mensaje .proto
, podemos suponer que el backend sabe cómo consultar el siguiente lote de tweets en función de la cadena del cursor.
El parámetro TimelineResponse.variables.seenTweetIds
se utiliza para informar al servidor sobre qué tweets de la página activa actualmente del desplazamiento infinito ya ha visto el cliente. Esto probablemente ayude a garantizar que el servidor no incluya tweets duplicados en las páginas de resultados posteriores.
Uno de los desafíos a resolver en las API como home timeline (o Home Feed) es descubrir cómo devolver las entidades vinculadas o jerárquicas (es decir, tweet → user
, tweet → media
, media → author
, etc.):
Veamos cómo lo maneja X.
Anteriormente, en el tipo de TimelineTweet
se utilizó el subtipo Tweet
. Veamos cómo se ve:
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[]; };
Lo interesante aquí es que la mayoría de los datos dependientes, como tweet → media
y tweet → author
están integrados en la respuesta en la primera llamada (sin consultas posteriores).
Además, las conexiones User
y Media
con las entidades Tweet
no están normalizadas (si dos tweets tienen el mismo autor, sus datos se repetirán en cada objeto de tweet). Pero parece que debería estar bien, ya que en el ámbito de la línea de tiempo de inicio para un usuario específico, los tweets serán escritos por muchos autores y las repeticiones son posibles, pero escasas.
Supuse que la API UserTweets
(que no cubrimos aquí), que es responsable de obtener los tweets de un usuario en particular, lo manejaría de manera diferente, pero, aparentemente, no es el caso. UserTweets
devuelve la lista de tweets del mismo usuario e incorpora los mismos datos de usuario una y otra vez para cada tweet. Es interesante. Tal vez la simplicidad del enfoque supere cierta sobrecarga de tamaño de datos (tal vez los datos de usuario se consideren bastante pequeños en tamaño). No estoy seguro.
Otra observación sobre la relación entre las entidades es que la entidad Media
también tiene un vínculo con el User
(el autor). Pero no lo hace a través de la incrustación directa de la entidad como lo hace la entidad Tweet
, sino que se vincula a través de la propiedad Media.source_user_id_str
.
Los "comentarios" (que también son los "tweets" por naturaleza) de cada "tweet" en la cronología de inicio no se obtienen en absoluto. Para ver el hilo de tweets, el usuario debe hacer clic en el tweet para ver su vista detallada. El hilo de tweets se obtendrá llamando al punto final TweetDetail
(más información en la sección "Página de detalles del tweet" a continuación).
Otra entidad que tiene cada Tweet
es FeedbackActions
(es decir, "Recomendar con menos frecuencia" o "Ver menos"). La forma en que se almacenan las FeedbackActions
en el objeto de respuesta es diferente de la forma en que se almacenan los objetos User
y Media
. Mientras que las entidades User
y Media
son parte del Tweet
, las FeedbackActions
se almacenan por separado en la matriz TimelineItem.content.feedbackInfo.feedbackKeys
y están vinculadas a través de ActionKey
. Eso fue una ligera sorpresa para mí, ya que no parece ser el caso de que cualquier acción sea reutilizable. Parece que una acción se utiliza solo para un tweet en particular. Entonces parece que las FeedbackActions
podrían integrarse en cada tweet de la misma manera que las entidades Media
. Pero es posible que me esté perdiendo alguna complejidad oculta aquí (como el hecho de que cada acción puede tener acciones secundarias).
Más detalles sobre las acciones se encuentran en la sección “Acciones de Tweet” a continuación.
El orden de clasificación de las entradas de la línea de tiempo lo define el backend a través de las propiedades 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', }; };
El sortIndex
en sí podría verse así '1867231621095096312'
. Probablemente corresponde directamente a un índice o se deriva de él.
En realidad, la mayoría de los ID que ves en la respuesta (ID de tweet) siguen la convención "Snowflake ID" y lucen como '1867231621095096312'
.
Si se utiliza para ordenar entidades como tweets, el sistema aprovecha la clasificación cronológica inherente de los identificadores de Snowflake. Los tweets u objetos con un valor sortIndex más alto (una marca de tiempo más reciente) aparecen en una posición más alta en el feed, mientras que aquellos con valores más bajos (una marca de tiempo más antigua) aparecen en una posición más baja en el feed.
Aquí está la decodificación paso a paso del ID de Snowflake (en nuestro caso el sortIndex
) 1867231621095096312
:
1867231621095096312 → 445182709954
445182709954 + 1288834974657 → 1734017684611ms
1734017684611ms → 2024-12-12 15:34:44.611 (UTC)
Así que podemos asumir aquí que los tweets en la línea de tiempo de inicio están ordenados cronológicamente.
Cada tweet tiene un menú "Acciones".
Las acciones para cada tweet provienen del backend en una matriz TimelineItem.content.feedbackInfo.feedbackKeys
y están vinculadas con los tweets a través de 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' }; };
Es interesante que esta matriz plana de acciones sea en realidad un árbol (¿o un gráfico? No lo he comprobado), ya que cada acción puede tener acciones secundarias (consulte la matriz TimelineAction.value.childKeys
). Esto tiene sentido, por ejemplo, cuando después de que el usuario hace clic en la acción "No me gusta" , el seguimiento podría ser mostrar la acción "Esta publicación no es relevante" , como una forma de explicar por qué al usuario no le gusta el tuit.
Una vez que el usuario desea ver la página de detalles del tweet (es decir, ver el hilo de comentarios/tweets), el usuario hace clic en el tweet y se realiza la solicitud GET
al siguiente punto final:
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}
Tenía curiosidad por saber por qué la lista de tweets se obtiene a través de la llamada POST
, pero cada detalle de tweet se obtiene a través de la llamada GET
. Parece inconsistente. Especialmente teniendo en cuenta que los parámetros de consulta similares como query-id
, features
y otros esta vez se pasan en la URL y no en el cuerpo de la solicitud. El formato de respuesta también es similar y está reutilizando los tipos de la llamada de lista. No estoy seguro de por qué es eso. Pero nuevamente, estoy seguro de que podría estar pasando por alto alguna complejidad de fondo aquí.
A continuación se muestran los tipos de cuerpo de respuesta simplificados:
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', }; };
La respuesta es bastante similar (en sus tipos) a la respuesta de la lista, por lo que no nos extenderemos demasiado aquí.
Un matiz interesante es que los "comentarios" (o conversaciones) de cada tweet son en realidad otros tweets (ver el tipo TimelineModule
). Por lo tanto, el hilo de tweets se parece mucho a la fuente de la línea de tiempo de inicio al mostrar la lista de entradas de TimelineTweet
. Esto se ve elegante. Un buen ejemplo de un enfoque universal y reutilizable para el diseño de API.
Cuando a un usuario le gusta el tweet, se realiza la solicitud POST
al siguiente punto final:
POST https://x.com/i/api/graphql/{query-id}/FavoriteTweet
Aquí están los tipos de cuerpo de la solicitud :
type FavoriteTweetRequest = { variables: { tweet_id: string; // '1867041249938530657' }; queryId: string; // 'lI07N61twFgted2EgXILM7A' };
Aquí están los tipos de cuerpo de respuesta :
type FavoriteTweetResponse = { data: { favorite_tweet: 'Done', } }
Parece sencillo y también se asemeja al enfoque tipo RPC para el diseño de API.
Hemos abordado algunas partes básicas del diseño de la API de la línea de tiempo de inicio al observar el ejemplo de API de X. Hice algunas suposiciones a lo largo del camino según mi leal saber y entender. Creo que algunas cosas podrían haber sido interpretadas incorrectamente y podría haber pasado por alto algunos matices complejos. Pero incluso con eso en mente, espero que haya obtenido algunas ideas útiles de esta descripción general de alto nivel, algo que pueda aplicar en su próxima sesión de diseño de API.
Inicialmente, tenía pensado visitar sitios web similares sobre tecnología de punta para obtener información de Facebook, Reddit, YouTube y otros, y recopilar las mejores prácticas y soluciones probadas en la práctica. No estoy seguro de si encontraré tiempo para hacerlo. Ya veremos. Pero podría ser un ejercicio interesante.
Como referencia, aquí agrego todos los tipos de una sola vez. También puedes encontrar todos los tipos en
/** * 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;