Pokiaľ ide o návrh rozhrania API systému, softvéroví inžinieri často zvažujú rôzne možnosti, ako napr
V tomto článku skúmame, ako je navrhnuté rozhranie API domovskej časovej osi X ( Twitter ) (x.com/home) a aké prístupy používajú na riešenie nasledujúcich problémov:
Ako získať zoznam tweetov
Ako urobiť triedenie a stránkovanie
Ako vrátiť hierarchické/prepojené entity (tweety, používatelia, médiá)
Ako získať podrobnosti o tweete
Ako dať „páči sa mi“ tweet
Tieto výzvy preskúmame iba na úrovni API, pričom implementáciu backendu budeme považovať za čiernu skrinku, keďže nemáme prístup k samotnému kódu backendu.
Zobrazenie presných požiadaviek a odpovedí tu môže byť ťažkopádne a ťažko sledovateľné, pretože hlboko vnorené a opakujúce sa objekty sa ťažko čítajú. Aby som uľahčil zobrazenie štruktúry užitočného zaťaženia požiadavky/odpovede, pokúsil som sa „napísať“ rozhranie API domácej časovej osi v TypeScript. Takže pokiaľ ide o príklady žiadosti/odpovede, použijem namiesto skutočných objektov JSON typy žiadostí a odpovedí. Pamätajte tiež, že typy sú zjednodušené a mnohé vlastnosti sú pre stručnosť vynechané.
Všetky typy nájdete v
Načítanie zoznamu tweetov pre domácu časovú os začína požiadavkou POST
na nasledujúci koncový bod:
POST https://x.com/i/api/graphql/{query-id}/HomeTimeline
Tu je zjednodušený typ tela žiadosti :
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; // ... }
A tu je zjednodušený typ tela odpovede (nižšie sa ponoríme hlbšie do podtypov odpovede):
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;
Je zaujímavé poznamenať, že „získanie“ údajov sa vykonáva prostredníctvom „POSTingu“, čo nie je bežné pre API podobné REST, ale je bežné pre API podobné GraphQL. Časť graphql
adresy URL tiež naznačuje, že X používa pre svoje API verziu GraphQL.
Používam tu slovo „príchuť“, pretože samotné telo požiadavky nevyzerá ako čisté
# 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 # ... } # ... } }
Predpokladom je, že rozhranie API domácej časovej osi nie je čisté rozhranie GraphQL API, ale je to zmes niekoľkých prístupov . Odovzdanie parametrov v požiadavke POST sa zdá byť bližšie k "funkčnému" volaniu RPC. Zároveň sa však zdá, že funkcie GraphQL možno použiť niekde na backende za obslužným programom/ovládačom koncového bodu HomeTimeline . Takýto mix môže byť spôsobený aj starým kódom alebo nejakým druhom prebiehajúcej migrácie. Ale opäť sú to len moje špekulácie.
Môžete si tiež všimnúť, že rovnaký TimelineRequest.queryId
sa používa v adrese URL rozhrania API, ako aj v tele žiadosti rozhrania API. Toto queryId sa s najväčšou pravdepodobnosťou vygeneruje na backende, potom sa vloží do balíka main.js
a potom sa použije pri načítaní údajov z backendu. Je pre mňa ťažké pochopiť, ako sa toto queryId
presne používa, pretože backend X je v našom prípade čierna skrinka. Ale opäť tu možno špekulovať o tom, že to môže byť potrebné pre určitý druh optimalizácie výkonu (opätovné použitie niektorých vopred vypočítaných výsledkov dotazu?), ukladanie do vyrovnávacej pamäte (súvisiace s Apollom?), ladenie (pripojenie k protokolom pomocou queryId?), alebo na účely sledovania/sledovania.
Je tiež zaujímavé poznamenať, že TimelineResponse
neobsahuje zoznam tweetov, ale skôr zoznam inštrukcií , ako napríklad „pridať tweet na časovú os“ (pozri typ TimelineAddEntries
) alebo „ukončiť časovú os“ (pozri TimelineTerminateTimeline
typ).
Samotná inštrukcia TimelineAddEntries
môže tiež obsahovať rôzne typy entít:
TimelineItem
TimelineCursor
TimelineModule
type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // <-- Here // ... }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // <-- Here };
To je zaujímavé z hľadiska rozšíriteľnosti, pretože umožňuje širšiu škálu toho, čo je možné vykresliť na domácej časovej osi bez prílišného vylaďovania API.
Vlastnosť TimelineRequest.variables.count
nastavuje, koľko tweetov chceme načítať naraz (na stránku). Predvolená hodnota je 20. V poli TimelineAddEntries.entries
však možno vrátiť viac ako 20 tweetov. Napríklad pole môže obsahovať 37 položiek pre prvé načítanie stránky, pretože obsahuje tweety (29), pripnuté tweety (1), propagované tweety (5) a kurzory stránkovania (2). Nie som si istý, prečo existuje 29 bežných tweetov s požadovaným počtom 20.
TimelineRequest.variables.cursor
je zodpovedný za stránkovanie založené na kurzore.
" Stránkovanie kurzorom sa najčastejšie používa pre údaje v reálnom čase kvôli frekvencii pridávania nových záznamov a preto, že pri čítaní údajov často vidíte ako prvé najnovšie výsledky. Eliminuje možnosť preskakovania položiek a zobrazenia tej istej položky viackrát. V stránkovanie založené na kurzore, konštantný ukazovateľ (alebo kurzor) sa používa na sledovanie toho, odkiaľ v množine údajov by sa mali načítať ďalšie položky." Pozrite si
Pri prvom načítaní zoznamu tweetov je TimelineRequest.variables.cursor
prázdny, pretože chceme načítať najlepšie tweety z predvoleného (pravdepodobne vopred vypočítaného) zoznamu prispôsobených tweetov.
V odpovedi však spolu s údajmi tweetu backend vráti aj položky kurzora. Tu je hierarchia typov odpovede: 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'; }; };
Každá stránka obsahuje zoznam tweetov spolu s „horným“ a „dolným“ kurzorom:
Po načítaní údajov stránky môžeme prejsť z aktuálnej stránky oboma smermi a načítať buď „predchádzajúce/staršie“ tweety pomocou „dolného“ kurzora, alebo „nasledujúce/novšie“ tweety pomocou „horného“ kurzora. Predpokladám, že načítanie „ďalších“ tweetov pomocou „horného“ kurzora sa deje v dvoch prípadoch: keď boli nové tweety pridané, keď používateľ stále číta aktuálnu stránku, alebo keď používateľ začne posúvať informačný kanál smerom nahor (a existujú žiadne záznamy vo vyrovnávacej pamäti alebo ak boli predchádzajúce záznamy vymazané z dôvodov výkonu).
Samotný kurzor X môže vyzerať takto: DAABCgABGemI6Mk__9sKAAIZ6MSYG9fQGwgAAwAAAAIAAA
. V niektorých návrhoch API môže byť kurzorom kódovaný reťazec Base64, ktorý obsahuje id poslednej položky v zozname alebo časovú pečiatku poslednej zobrazenej položky. Napríklad: eyJpZCI6ICIxMjM0NTY3ODkwIn0= --> {"id": "1234567890"}
a potom sa tieto údaje použijú na zodpovedajúci dotaz v databáze. V prípade X API to vyzerá, že kurzor sa dekóduje Base64 do nejakej vlastnej binárnej sekvencie, ktorá si môže vyžadovať ďalšie dekódovanie, aby z toho získal nejaký význam (tj prostredníctvom definícií správ Protobuf). Keďže nevieme, či ide o kódovanie .proto
a nepoznáme ani definíciu správy .proto
, môžeme len predpokladať, že backend vie, ako sa pýtať na ďalšiu dávku tweetov na základe reťazca kurzora.
Parameter TimelineResponse.variables.seenTweetIds
sa používa na informovanie servera o tom, ktoré tweety z aktuálne aktívnej stránky nekonečného rolovania už klient videl. To s najväčšou pravdepodobnosťou pomáha zabezpečiť, aby server nezahŕňal duplicitné tweety na nasledujúcich stránkach výsledkov.
Jednou z výziev, ktoré treba vyriešiť v rozhraniach API, ako je domáca časová os (alebo domovský kanál), je zistiť, ako vrátiť prepojené alebo hierarchické entity (tj tweet → user
, tweet → media
, media → author
atď.):
Pozrime sa, ako si s tým X poradí.
Predtým sa v type TimelineTweet
používal podtyp Tweet
. Pozrime sa, ako to vyzerá:
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[]; };
Zaujímavé je, že väčšina závislých údajov ako tweet → media
a tweet → author
je vložená do odpovede pri prvom hovore (žiadne následné otázky).
Tiež prepojenia User
a Media
s entitami Tweet
nie sú normalizované (ak majú dva tweety rovnakého autora, ich údaje sa budú opakovať v každom objekte tweetu). Zdá sa však, že by to malo byť v poriadku, keďže v rámci domovskej časovej osi pre konkrétneho používateľa budú tweety autormi mnohých autorov a opakovania sú možné, ale sú zriedkavé.
Predpokladal som, že rozhranie UserTweets
API (ktoré tu nepokrývame), ktoré je zodpovedné za načítanie tweetov jedného konkrétneho používateľa, s tým bude zaobchádzať inak, ale zjavne to tak nie je. UserTweets
vráti zoznam tweetov toho istého používateľa a do každého tweetu znova a znova vloží rovnaké používateľské údaje. Je to zaujímavé. Možno, že jednoduchosť prístupu prekonáva určitú réžiu veľkosti údajov (možno sa používateľské údaje považujú za dosť malé). nie som si istý.
Ďalším postrehom o vzťahu entít je, že entita Media
má tiež prepojenie na User
(autora). Nerobí to však prostredníctvom priameho vkladania entity ako entita Tweet
, ale prepája sa prostredníctvom vlastnosti Media.source_user_id_str
.
„Komentáre“ (ktoré sú svojou povahou tiež „tweety“) pre každý „tweet“ na domovskej časovej osi sa vôbec nenačítajú. Na zobrazenie vlákna tweetu musí používateľ kliknúť na tweet, aby sa mu zobrazilo jeho podrobné zobrazenie. Vlákno tweetu sa načíta volaním koncového bodu TweetDetail
(viac o ňom v sekcii „Podrobná stránka tweetu“ nižšie).
Ďalšou entitou, ktorú má každý Tweet
, je FeedbackActions
(tj „Odporúčať menej často“ alebo „Zobraziť menej“). Spôsob, akým sú FeedbackActions
uložené v objekte odpovede, sa líši od spôsobu, akým sú uložené objekty User
a Media
. Zatiaľ čo entity User
a Media
sú súčasťou Tweet
, FeedbackActions
sú uložené oddelene v poli TimelineItem.content.feedbackInfo.feedbackKeys
a sú prepojené pomocou ActionKey
. To bolo pre mňa mierne prekvapenie, pretože sa nezdá, že by bola akákoľvek akcia znovu použiteľná. Zdá sa, že jedna akcia sa používa iba pre jeden konkrétny tweet. Zdá sa teda, že FeedbackActions
by mohli byť vložené do každého tweetu rovnakým spôsobom ako Media
entity. Ale možno mi tu chýba nejaká skrytá zložitosť (napríklad skutočnosť, že každá akcia môže mať akcie detí).
Viac podrobností o akciách nájdete v sekcii „Akcie tweetov“ nižšie.
Poradie triedenia položiek časovej osi je definované koncovým serverom prostredníctvom vlastností 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', }; };
Samotný sortIndex
môže vyzerať nejako takto '1867231621095096312'
. Pravdepodobne priamo zodpovedá alebo je odvodený od a
V skutočnosti väčšina ID, ktoré vidíte v odpovedi (ID tweetu), sa riadi konvenciou „ID snehovej vločky“ a vyzerá ako '1867231621095096312'
.
Ak sa to používa na triedenie entít, ako sú tweety, systém využíva inherentné chronologické triedenie ID snehových vločiek. Tweety alebo objekty s vyššou hodnotou sortIndex (novšia časová pečiatka) sa v informačnom kanáli zobrazujú vyššie, zatiaľ čo objekty s nižšími hodnotami (staršia časová pečiatka) sa v informačnom kanáli zobrazujú nižšie.
Tu je krok za krokom dekódovanie ID snehovej vločky (v našom prípade sortIndex
) 1867231621095096312
:
1867231621095096312 → 445182709954
445182709954 + 1288834974657 → 1734017684611ms
1734017684611ms → 2024-12-12 15:34:44.611 (UTC)
Tu teda môžeme predpokladať, že tweety v domovskej časovej osi sú zoradené chronologicky.
Každý tweet má ponuku „Akcie“.
Akcie pre každý tweet pochádzajú z backendu v poli TimelineItem.content.feedbackInfo.feedbackKeys
a sú prepojené s tweetmi prostredníctvom 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' }; };
Je zaujímavé, že toto ploché pole akcií je vlastne strom (alebo graf? Nekontroloval som), keďže každá akcia môže mať podradené akcie (pozri pole TimelineAction.value.childKeys
). Dáva to zmysel napríklad vtedy, keď používateľ klikne na akciu „Nepáči sa mi to“ , následným krokom môže byť zobrazenie akcie „Tento príspevok nie je relevantný“ , ako spôsob vysvetlenia, prečo používateľ tweet sa mi nepáči.
Keď chce používateľ zobraziť stránku s podrobnosťami o tweete (tj vidieť vlákno komentárov/tweetov), klikne na tweet a vykoná sa požiadavka GET
na nasledujúci koncový bod:
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}
Bol som zvedavý, prečo sa zoznam tweetov získava prostredníctvom volania POST
, ale každý detail tweetu sa získava prostredníctvom volania GET
. Zdá sa nekonzistentné. Predovšetkým majte na pamäti, že podobné parametre dopytu, ako napríklad query-id
, features
a ďalšie, sa tentoraz odovzdávajú v adrese URL a nie v tele žiadosti. Formát odpovede je tiež podobný a opätovne používa typy z volania zoznamu. Nie som si istý, prečo je to tak. Ale opäť som si istý, že mi tu môže chýbať určitá zložitosť pozadia.
Tu sú zjednodušené typy odpovedí:
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', }; };
Odpoveď je dosť podobná (vo svojich typoch) odpovedi zoznamu, takže tu nebudeme príliš dlho.
Jednou zaujímavou nuansou je, že „komentáre“ (alebo konverzácie) každého tweetu sú v skutočnosti iné tweety (pozri typ TimelineModule
). Vlákno tweetu teda vyzerá veľmi podobne ako domáci kanál časovej osi, pretože zobrazuje zoznam položiek TimelineTweet
. Toto vyzerá elegantne. Dobrý príklad univerzálneho a znovu použiteľného prístupu k dizajnu API.
Keď sa používateľovi páči tweet, vykoná sa požiadavka POST
na nasledujúci koncový bod:
POST https://x.com/i/api/graphql/{query-id}/FavoriteTweet
Tu sú typy tela žiadosti :
type FavoriteTweetRequest = { variables: { tweet_id: string; // '1867041249938530657' }; queryId: string; // 'lI07N61twFgted2EgXILM7A' };
Tu sú typy tela odpovede :
type FavoriteTweetResponse = { data: { favorite_tweet: 'Done', } }
Vyzerá priamočiaro a tiež pripomína RPC prístup k dizajnu API.
Dotkli sme sa niektorých základných častí dizajnu API domácej časovej osi pri pohľade na príklad API X. Po ceste som urobil nejaké predpoklady, ako som najlepšie vedel. Verím, že niektoré veci som si možno nesprávne vysvetlil a možno mi ušli niektoré zložité nuansy. Ale aj s ohľadom na to dúfam, že ste z tohto prehľadu na vysokej úrovni získali užitočné informácie, ktoré by ste mohli použiť vo svojej ďalšej relácii API Design.
Pôvodne som mal v pláne prejsť podobné špičkové webové stránky, aby som získal prehľad z Facebooku, Redditu, YouTube a ďalších a zhromaždil osvedčené postupy a riešenia overené v boji. Nie som si istý, či si na to nájdem čas. Uvidíme. Ale mohlo by to byť zaujímavé cvičenie.
Pre referenciu sem pridávam všetky typy naraz. Všetky typy nájdete aj v
/** * 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;