Ketika merancang API sistem, para insinyur perangkat lunak sering mempertimbangkan berbagai opsi seperti
Dalam artikel ini, kami membahas bagaimana API linimasa beranda X ( Twitter ) (x.com/home) dirancang dan pendekatan apa yang mereka gunakan untuk mengatasi tantangan berikut:
Cara mengambil daftar tweet
Cara melakukan penyortiran dan pagination
Cara mengembalikan entitas hierarkis/tertaut (tweet, pengguna, media)
Cara mendapatkan detail tweet
Cara "menyukai" tweet
Kami hanya akan mengeksplorasi tantangan ini pada level API, memperlakukan implementasi backend sebagai kotak hitam, karena kami tidak memiliki akses ke kode backend itu sendiri.
Menampilkan permintaan dan respons yang tepat di sini mungkin merepotkan dan sulit diikuti karena objek yang sangat bersarang dan berulang sulit dibaca. Untuk mempermudah melihat struktur muatan permintaan/respons, saya telah mencoba untuk "mengetik" API linimasa beranda dalam TypeScript. Jadi, jika menyangkut contoh permintaan/respons, saya akan menggunakan tipe permintaan dan respons, bukan objek JSON yang sebenarnya. Selain itu, ingatlah bahwa tipe-tipe tersebut disederhanakan dan banyak properti dihilangkan demi singkatnya.
Anda dapat menemukan semua jenis di
Pengambilan daftar tweet untuk linimasa beranda dimulai dengan permintaan POST
ke titik akhir berikut:
POST https://x.com/i/api/graphql/{query-id}/HomeTimeline
Berikut ini adalah tipe badan permintaan yang disederhanakan:
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; // ... }
Dan berikut ini adalah tipe isi respons yang disederhanakan (kita akan membahas lebih dalam subtipe respons di bawah):
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;
Menarik untuk dicatat di sini, bahwa "mendapatkan" data dilakukan melalui "POSTing", yang tidak umum untuk API seperti REST tetapi umum untuk API seperti GraphQL. Selain itu, bagian graphql
dari URL menunjukkan bahwa X menggunakan GraphQL untuk API mereka.
Saya menggunakan kata "rasa" di sini karena isi permintaan itu sendiri tidak terlihat seperti rasa murni.
# 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 # ... } # ... } }
Asumsinya di sini adalah bahwa API linimasa beranda bukanlah API GraphQL murni, tetapi merupakan campuran dari beberapa pendekatan . Melewati parameter dalam permintaan POST seperti ini tampaknya lebih dekat dengan panggilan RPC "fungsional". Namun pada saat yang sama, tampaknya fitur GraphQL mungkin digunakan di suatu tempat di bagian belakang di belakang pengendali/pengendali titik akhir HomeTimeline . Campuran seperti ini mungkin juga disebabkan oleh kode lama atau semacam migrasi yang sedang berlangsung. Namun sekali lagi, ini hanyalah spekulasi saya.
Anda mungkin juga memperhatikan bahwa TimelineRequest.queryId
yang sama digunakan di URL API serta di badan permintaan API. QueryId ini kemungkinan besar dibuat di backend, kemudian disematkan di bundel main.js
, lalu digunakan saat mengambil data dari backend. Sulit bagi saya untuk memahami bagaimana queryId
ini digunakan secara tepat karena backend X adalah kotak hitam dalam kasus kami. Namun, sekali lagi, spekulasi di sini mungkin adalah, queryId ini mungkin diperlukan untuk beberapa jenis pengoptimalan kinerja (menggunakan kembali beberapa hasil kueri yang telah dihitung sebelumnya?), caching (terkait Apollo?), debugging (menggabungkan log dengan queryId?), atau tujuan pelacakan/penelusuran.
Menarik untuk dicatat, bahwa TimelineResponse
tidak berisi daftar tweet, melainkan daftar instruksi , seperti "tambahkan tweet ke timeline" (lihat tipe TimelineAddEntries
), atau "hentikan timeline" (lihat tipe TimelineTerminateTimeline
).
Instruksi TimelineAddEntries
sendiri juga dapat berisi berbagai jenis entitas:
TimelineItem
TimelineCursor
TimelineModule
type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // <-- Here // ... }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // <-- Here };
Hal ini menarik dari sudut pandang perluasan karena memungkinkan variasi yang lebih luas dari apa yang dapat ditampilkan di timeline beranda tanpa terlalu banyak mengubah API.
Properti TimelineRequest.variables.count
menetapkan berapa banyak tweet yang ingin kita ambil sekaligus (per halaman). Nilai default adalah 20. Namun, lebih dari 20 tweet dapat dikembalikan dalam array TimelineAddEntries.entries
. Misalnya, array tersebut mungkin berisi 37 entri untuk pemuatan halaman pertama, karena array tersebut mencakup tweet (29), tweet yang disematkan (1), tweet yang dipromosikan (5), dan kursor pagination (2). Saya tidak yakin mengapa ada 29 tweet biasa dengan jumlah yang diminta sebesar 20.
TimelineRequest.variables.cursor
bertanggung jawab atas pagination berbasis kursor.
" Paging kursor paling sering digunakan untuk data waktu nyata karena frekuensi penambahan catatan baru dan karena saat membaca data, Anda sering kali melihat hasil terbaru terlebih dahulu. Ini menghilangkan kemungkinan melewatkan item dan menampilkan item yang sama lebih dari sekali. Dalam pagination berbasis kursor, pointer konstan (atau kursor) digunakan untuk melacak di mana dalam set data item berikutnya harus diambil." Lihat
Saat mengambil daftar tweet untuk pertama kalinya, TimelineRequest.variables.cursor
kosong, karena kami ingin mengambil tweet teratas dari daftar tweet personalisasi default (kemungkinan besar telah dihitung sebelumnya).
Namun, dalam respons, bersama dengan data tweet, backend juga mengembalikan entri kursor. Berikut hierarki jenis respons: 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'; }; };
Setiap halaman berisi daftar tweet beserta kursor "atas" dan "bawah":
Setelah data halaman dimuat, kita dapat berpindah dari halaman saat ini ke kedua arah dan mengambil tweet "sebelumnya/lama" menggunakan kursor "bawah" atau tweet "berikutnya/baru" menggunakan kursor "atas". Asumsi saya adalah bahwa mengambil tweet "berikutnya" menggunakan kursor "atas" terjadi dalam dua kasus: ketika tweet baru ditambahkan saat pengguna masih membaca halaman saat ini, atau ketika pengguna mulai menggulir umpan ke atas (dan tidak ada entri yang di-cache atau jika entri sebelumnya dihapus karena alasan kinerja).
Kursor X itu sendiri mungkin terlihat seperti ini: DAABCgABGemI6Mk__9sKAAIZ6MSYG9fQGwgAAwAAAAIAAA
. Dalam beberapa desain API, kursor mungkin berupa string yang dikodekan Base64 yang berisi id entri terakhir dalam daftar, atau stempel waktu entri terakhir yang terlihat. Misalnya: eyJpZCI6ICIxMjM0NTY3ODkwIn0= --> {"id": "1234567890"}
, dan kemudian, data ini digunakan untuk mengkueri basis data sebagaimana mestinya. Dalam kasus API X, sepertinya kursor sedang didekodekan Base64 menjadi beberapa urutan biner khusus yang mungkin memerlukan beberapa dekode lebih lanjut untuk mendapatkan makna darinya (yaitu melalui definisi pesan Protobuf). Karena kita tidak tahu apakah itu adalah pengodean .proto
dan juga tidak tahu definisi pesan .proto
, kita mungkin berasumsi bahwa backend tahu cara menanyakan kumpulan tweet berikutnya berdasarkan string kursor.
Parameter TimelineResponse.variables.seenTweetIds
digunakan untuk memberi tahu server tentang tweet mana dari halaman yang sedang aktif dengan pengguliran tak terbatas yang telah dilihat klien. Hal ini kemungkinan besar membantu memastikan bahwa server tidak menyertakan tweet duplikat di halaman hasil berikutnya.
Salah satu tantangan yang harus dipecahkan dalam API seperti timeline beranda (atau Home Feed) adalah mencari cara untuk mengembalikan entitas yang terhubung atau hierarkis (misalnya tweet → user
, tweet → media
, media → author
, dan lain sebagainya):
Mari kita lihat bagaimana X menanganinya.
Sebelumnya pada tipe TimelineTweet
subtipe Tweet
digunakan. Mari kita lihat bagaimana tampilannya:
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[]; };
Yang menarik di sini adalah sebagian besar data dependen seperti tweet → media
dan tweet → author
disematkan ke dalam respons pada panggilan pertama (tidak ada kueri berikutnya).
Selain itu, koneksi User
dan Media
dengan entitas Tweet
tidak dinormalisasi (jika dua tweet memiliki penulis yang sama, datanya akan diulang di setiap objek tweet). Namun tampaknya hal itu tidak masalah, karena dalam lingkup linimasa beranda untuk pengguna tertentu, tweet akan ditulis oleh banyak penulis dan pengulangan mungkin terjadi tetapi jarang.
Asumsi saya adalah bahwa API UserTweets
(yang tidak kami bahas di sini), yang bertanggung jawab untuk mengambil tweet dari satu pengguna tertentu akan menanganinya secara berbeda, tetapi, tampaknya, bukan itu masalahnya. UserTweets
mengembalikan daftar tweet dari pengguna yang sama dan menyematkan data pengguna yang sama berulang-ulang untuk setiap tweet. Ini menarik. Mungkin kesederhanaan pendekatan ini mengalahkan beberapa overhead ukuran data (mungkin data pengguna dianggap cukup kecil ukurannya). Saya tidak yakin.
Pengamatan lain tentang hubungan entitas adalah bahwa entitas Media
juga memiliki tautan ke User
(penulis). Namun, hal itu tidak dilakukan melalui penyematan entitas secara langsung seperti yang dilakukan entitas Tweet
, melainkan melalui properti Media.source_user_id_str
.
"Komentar" (yang juga merupakan "tweet" menurut sifatnya) untuk setiap "tweet" di linimasa beranda tidak diambil sama sekali. Untuk melihat utas tweet, pengguna harus mengeklik tweet untuk melihat tampilan terperincinya. Utas tweet akan diambil dengan memanggil titik akhir TweetDetail
(lebih lanjut tentangnya di bagian "Halaman detail tweet" di bawah).
Entitas lain yang dimiliki setiap Tweet
adalah FeedbackActions
(yaitu "Rekomendasikan lebih jarang" atau "Lihat lebih sedikit"). Cara FeedbackActions
disimpan dalam objek respons berbeda dari cara objek User
dan Media
disimpan. Sementara entitas User
dan Media
merupakan bagian dari Tweet
, FeedbackActions
disimpan secara terpisah dalam array TimelineItem.content.feedbackInfo.feedbackKeys
dan ditautkan melalui ActionKey
. Itu sedikit mengejutkan bagi saya karena tampaknya tidak ada tindakan yang dapat digunakan kembali. Sepertinya satu tindakan digunakan untuk satu tweet tertentu saja. Jadi sepertinya FeedbackActions
dapat disematkan ke setiap tweet dengan cara yang sama seperti entitas Media
. Namun, saya mungkin melewatkan beberapa kerumitan tersembunyi di sini (seperti fakta bahwa setiap tindakan dapat memiliki tindakan turunan).
Rincian lebih lanjut tentang tindakan tersebut ada di bagian "Tindakan Tweet" di bawah.
Urutan penyortiran entri garis waktu ditentukan oleh backend melalui properti 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
itu sendiri mungkin terlihat seperti ini '1867231621095096312'
. Kemungkinan besar itu berhubungan langsung dengan atau berasal dari
Sebenarnya sebagian besar ID yang Anda lihat dalam respons (ID tweet) mengikuti konvensi "ID Snowflake" dan terlihat seperti '1867231621095096312'
.
Jika ini digunakan untuk mengurutkan entitas seperti tweet, sistem memanfaatkan pengurutan kronologis bawaan ID Snowflake. Tweet atau objek dengan nilai sortIndex yang lebih tinggi (stempel waktu yang lebih baru) muncul lebih tinggi di feed, sedangkan yang memiliki nilai lebih rendah (stempel waktu yang lebih lama) muncul lebih rendah di feed.
Berikut ini adalah decoding ID Snowflake (dalam kasus kami sortIndex
) 1867231621095096312
langkah demi langkah:
1867231621095096312 → 445182709954
445182709954 + 1288834974657 → 1734017684611ms
1734017684611ms → 2024-12-12 15:34:44.611 (UTC)
Jadi kita dapat berasumsi di sini bahwa tweet di timeline beranda diurutkan secara kronologis.
Setiap tweet memiliki menu "Tindakan".
Tindakan untuk setiap tweet berasal dari backend dalam array TimelineItem.content.feedbackInfo.feedbackKeys
dan ditautkan dengan tweet melalui 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' }; };
Yang menarik di sini adalah bahwa rangkaian tindakan yang datar ini sebenarnya adalah pohon (atau grafik? Saya tidak memeriksa), karena setiap tindakan mungkin memiliki tindakan turunan (lihat rangkaian TimelineAction.value.childKeys
). Ini masuk akal, misalnya, ketika setelah pengguna mengklik tindakan "Tidak Suka" , tindak lanjutnya mungkin menunjukkan tindakan "Posting ini tidak relevan" , sebagai cara menjelaskan mengapa pengguna tidak menyukai tweet tersebut.
Setelah pengguna ingin melihat halaman detail tweet (yaitu untuk melihat rangkaian komentar/tweet), pengguna mengklik tweet tersebut dan permintaan GET
ke titik akhir berikut dilakukan:
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}
Saya penasaran mengapa daftar tweet diambil melalui panggilan POST
, tetapi setiap detail tweet diambil melalui panggilan GET
. Tampaknya tidak konsisten. Terutama mengingat bahwa parameter kueri serupa seperti query-id
, features
, dan lainnya kali ini dilewatkan di URL dan bukan di badan permintaan. Format respons juga serupa dan menggunakan kembali tipe dari panggilan daftar. Saya tidak yakin mengapa demikian. Namun sekali lagi, saya yakin saya mungkin melewatkan beberapa kerumitan latar belakang di sini.
Berikut ini adalah tipe badan respons yang disederhanakan:
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', }; };
Responsnya cukup mirip (dalam jenisnya) dengan respons daftar, jadi kita tidak akan membahasnya terlalu lama di sini.
Satu hal yang menarik adalah bahwa "komentar" (atau percakapan) dari setiap tweet sebenarnya adalah tweet lain (lihat tipe TimelineModule
). Jadi, utas tweet terlihat sangat mirip dengan umpan timeline beranda dengan menampilkan daftar entri TimelineTweet
. Ini terlihat elegan. Contoh yang bagus dari pendekatan universal dan dapat digunakan kembali untuk desain API.
Ketika pengguna menyukai tweet, permintaan POST
ke titik akhir berikut sedang dilakukan:
POST https://x.com/i/api/graphql/{query-id}/FavoriteTweet
Berikut ini adalah tipe badan permintaan :
type FavoriteTweetRequest = { variables: { tweet_id: string; // '1867041249938530657' }; queryId: string; // 'lI07N61twFgted2EgXILM7A' };
Berikut ini adalah tipe badan respon :
type FavoriteTweetResponse = { data: { favorite_tweet: 'Done', } }
Tampak lugas dan juga menyerupai pendekatan RPC pada desain API.
Kami telah menyentuh beberapa bagian dasar dari desain API linimasa beranda dengan melihat contoh API X. Saya membuat beberapa asumsi selama proses tersebut sejauh pengetahuan saya. Saya yakin beberapa hal mungkin telah saya tafsirkan secara salah dan saya mungkin telah melewatkan beberapa nuansa yang rumit. Namun, meskipun demikian, saya harap Anda memperoleh beberapa wawasan yang berguna dari ikhtisar tingkat tinggi ini, sesuatu yang dapat Anda terapkan dalam sesi Desain API berikutnya.
Awalnya, saya berencana untuk mengunjungi situs web teknologi papan atas serupa untuk mendapatkan beberapa wawasan dari Facebook, Reddit, YouTube, dan lainnya serta mengumpulkan praktik terbaik dan solusi yang telah teruji. Saya tidak yakin apakah saya akan punya waktu untuk melakukannya. Kita lihat saja nanti. Namun, ini bisa menjadi latihan yang menarik.
Sebagai referensi, saya menambahkan semua jenis sekaligus di sini. Anda juga dapat menemukan semua jenis di
/** * 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;