כשזה מגיע לעיצוב ה-API של המערכת, מהנדסי התוכנה שוקלים לעתים קרובות אפשרויות שונות כמו
במאמר זה, אנו חוקרים כיצד תוכנן ה-API של X ( Twitter ) הביתי (x.com/home) ובאילו גישות הם משתמשים כדי לפתור את האתגרים הבאים:
כיצד להביא את רשימת הציוצים
איך עושים מיון ועימוד
כיצד להחזיר את הישויות ההיררכיות/מקושרות (ציוצים, משתמשים, מדיה)
כיצד לקבל פרטי ציוץ
איך לעשות "לייק" לציוץ
אנו נחקור את האתגרים הללו רק ברמת ה-API, נתייחס ליישום ה-backend כאל קופסה שחורה, מכיוון שאין לנו גישה לקוד ה-backend עצמו.
הצגת הבקשות והתגובות המדויקות כאן עשויה להיות מסורבלת וקשה לעקוב מאחר שקשה לקרוא את האובייקטים המקוננים והחוזרים על עצמם. כדי שיהיה קל יותר לראות את מבנה עומס הבקשה/התגובה, עשיתי את הניסיון שלי "להקליד" את ה-API של ציר הזמן הביתי ב-TypeScript. אז כשזה מגיע לדוגמאות הבקשה/תגובה אני אשתמש בסוגי הבקשה והתגובות במקום באובייקטי JSON בפועל. כמו כן, זכור כי הסוגים מפושטים ומאפיינים רבים מושמטים לקיצור.
אתה עשוי למצוא את כל הסוגים ב
שליפת רשימת הציוצים עבור ציר הזמן הביתי מתחילה בבקשת POST
לנקודת הקצה הבאה:
POST https://x.com/i/api/graphql/{query-id}/HomeTimeline
להלן סוג גוף בקשה פשוט:
type TimelineRequest = { queryId: string; // 's6ERr1UxkxxBx4YundNsXw' variables: { count: number; // 20 cursor?: string; // 'DAAACgGBGedb3Vx__9sKAAIZ5g4QENc99AcAAwAAIAIAAA' seenTweetIds: string[]; // ['1867041249938530657', '1867041249938530659'] }; features: Features; }; type Features = { articles_preview_enabled: boolean; view_counts_everywhere_api_enabled: boolean; // ... }
והנה סוג גוף תשובה מפושט (אנחנו נצלול עמוק יותר לתוך תתי סוגי התגובות למטה):
type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; responseObjects: { feedbackActions: TimelineAction[]; }; }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; }; type TimelineItem = { entryId: string; // 'tweet-1867041249938530657' sortIndex: string; // '1866561576636152411' content: { __typename: 'TimelineTimelineItem'; itemContent: TimelineTweet; feedbackInfo: { feedbackKeys: ActionKey[]; // ['-1378668161'] }; }; }; type TimelineTweet = { __typename: 'TimelineTweet'; tweet_results: { result: Tweet; }; }; type TimelineCursor = { entryId: string; // 'cursor-top-1867041249938530657' sortIndex: string; // '1866961576813152212' content: { __typename: 'TimelineTimelineCursor'; value: string; // 'DACBCgABGedb4VyaJwuKbIIZ40cX3dYwGgaAAwAEAEEAA' cursorType: 'Top' | 'Bottom'; }; }; type ActionKey = string;
מעניין לציין כאן, ש"קבלת" הנתונים מתבצעת באמצעות "POSTing", דבר שאינו נפוץ עבור ה-API דמוי REST אך הוא נפוץ עבור API דמוי GraphQL. כמו כן, החלק graphql
של כתובת האתר מציין ש-X משתמש בטעם GraphQL עבור ה-API שלהם.
אני משתמש כאן במילה "טעם" כי גוף הבקשה עצמו לא נראה כמו טהור
# An example of a pure GraphQL request structure that is *not* being used in the X API. { tweets { id description created_at medias { kind url # ... } author { id name # ... } # ... } }
ההנחה כאן היא שה-API של ציר הזמן הביתי אינו API טהור של GraphQL, אלא הוא שילוב של מספר גישות . העברת הפרמטרים בבקשת POST כזו נראית קרובה יותר לקריאת ה-RPC ה"פונקציונלית". אבל יחד עם זאת, נראה שתכונות GraphQL עשויות לשמש איפשהו בקצה האחורי מאחורי המטפל/בקר של נקודת הקצה של HomeTimeline . תמהיל כזה עשוי להיגרם גם על ידי קוד מדור קודם או איזושהי הגירה מתמשכת. אבל שוב, אלו רק השערות שלי.
ייתכן שתבחין גם שאותו TimelineRequest.queryId
משמש בכתובת ה-API וכן בגוף בקשת ה-API. queryId זה נוצר ככל הנראה ב-backend, לאחר מכן הוא מוטבע בחבילת main.js
, ולאחר מכן הוא משמש בעת שליפת הנתונים מה-backend. קשה לי להבין כיצד נעשה שימוש queryId
הזה בדיוק מכיוון שהקצה האחורי של X הוא קופסה שחורה במקרה שלנו. אבל, שוב, ההשערה כאן עשויה להיות שאולי זה נחוץ עבור איזושהי אופטימיזציה של ביצועים (שימוש חוזר בתוצאות שאילתות מחושבות מראש?), שמירה במטמון (קשור לאפולו?), ניפוי באגים (הצטרפות ביומנים באמצעות queryId?), או למטרות מעקב/מעקב.
מעניין גם לציין שה- TimelineResponse
אינו מכיל רשימה של ציוצים, אלא רשימה של הוראות , כמו "הוסף ציוץ לציר הזמן" (ראה סוג TimelineAddEntries
), או "סיים את ציר הזמן" (ראה TimelineTerminateTimeline
סוּג).
הוראת TimelineAddEntries
עצמה עשויה להכיל גם סוגים שונים של ישויות:
TimelineItem
TimelineCursor
TimelineModule
type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // <-- Here // ... }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // <-- Here };
זה מעניין מנקודת המבט של יכולת ההרחבה מכיוון שהוא מאפשר מגוון רחב יותר של מה שניתן להציג בציר הזמן הביתי מבלי לשנות את ה-API יותר מדי.
המאפיין TimelineRequest.variables.count
קובע כמה ציוצים נרצה להביא בבת אחת (לעמוד). ברירת המחדל היא 20. עם זאת, ניתן להחזיר יותר מ-20 ציוצים במערך TimelineAddEntries.entries
. לדוגמה, המערך עשוי להכיל 37 ערכים עבור טעינת העמוד הראשון, מכיוון שהוא כולל ציוצים (29), ציוצים מוצמדים (1), ציוצים מקודמים (5) וסמני עימוד (2). אני לא בטוח מדוע יש 29 ציוצים רגילים עם הספירה המבוקשת של 20.
TimelineRequest.variables.cursor
אחראי לעימוד המבוסס על הסמן.
" עימוד סמן משמש לרוב לנתונים בזמן אמת בשל התדירות שרשומות חדשות מתווספות ומכיוון שבעת קריאת נתונים לרוב רואים קודם את התוצאות העדכניות ביותר. זה מבטל את האפשרות של דילוג על פריטים והצגת אותו פריט יותר מפעם אחת. ב עימוד מבוסס סמן, מצביע (או סמן) קבוע משמש כדי לעקוב מאיפה במערך הנתונים יש להביא את הפריטים הבאים." ראה את
בעת שליפת רשימת הציוצים בפעם הראשונה, TimelineRequest.variables.cursor
ריק, מכיוון שאנו רוצים להביא את הציוצים המובילים מרשימת הציוצים המותאמים אישית כברירת מחדל (ככל הנראה מחושבת מראש).
עם זאת, בתגובה, יחד עם נתוני הציוץ, ה-backend מחזיר גם את ערכי הסמן. להלן היררכיית סוגי התגובה: TimelineResponse → TimelineAddEntries → TimelineCursor
:
type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // <-- Here // ... }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // <-- Here (tweets + cursors) }; type TimelineCursor = { entryId: string; sortIndex: string; content: { __typename: 'TimelineTimelineCursor'; value: string; // 'DACBCgABGedb4VyaJwuKbIIZ40cX3dYwGgaAAwAEAEEAA' <-- Here cursorType: 'Top' | 'Bottom'; }; };
כל עמוד מכיל את רשימת הציוצים יחד עם הסמנים "למעלה" ו"למטה":
לאחר טעינת נתוני העמוד, נוכל לעבור מהעמוד הנוכחי לשני הכיוונים ולהביא את הציוצים "קודמים/ישנים" באמצעות הסמן "התחתון" או את הציוצים "הבא/חדש יותר" באמצעות הסמן "העליון". ההנחה שלי היא שליפת הציוצים "הבאים" באמצעות הסמן "העליון" מתרחשת בשני מקרים: כאשר הציוצים החדשים נוספו בזמן שהמשתמש עדיין קורא את העמוד הנוכחי, או כאשר המשתמש מתחיל לגלול את הפיד כלפי מעלה (וישנם אין ערכים במטמון או אם הערכים הקודמים נמחקו מסיבות הביצועים).
הסמן של ה-X עצמו עשוי להיראות כך: DAABCgABGemI6Mk__9sKAAIZ6MSYG9fQGwgAAwAAAAIAAA
. בעיצובי API מסוימים, הסמן עשוי להיות מחרוזת מקודדת Base64 המכילה את המזהה של הערך האחרון ברשימה, או את חותמת הזמן של הערך האחרון שנראה. לדוגמה: eyJpZCI6ICIxMjM0NTY3ODkwIn0= --> {"id": "1234567890"}
, ולאחר מכן, הנתונים הללו משמשים לשאילתה של מסד הנתונים בהתאם. במקרה של X API, נראה שהסמן מפוענח Base64 לאיזה רצף בינארי מותאם אישית שעשוי לדרוש פענוח נוסף כדי להוציא ממנו משמעות כלשהי (כלומר דרך הגדרות הודעת Protobuf). מכיוון שאיננו יודעים אם מדובר בקידוד .proto
וגם איננו יודעים את הגדרת הודעת .proto
אנו עשויים פשוט להניח שה-backend יודע כיצד לבצע שאילתות לקבוצת הציוצים הבאה על סמך מחרוזת הסמן.
הפרמטר TimelineResponse.variables.seenTweetIds
משמש ליידע את השרת על אילו ציוצים מהעמוד הפעיל כעת של הגלילה האינסופית שהלקוח כבר ראה. זה כנראה עוזר להבטיח שהשרת לא יכלול ציוצים כפולים בדפי התוצאות הבאים.
אחד האתגרים שיש לפתור בממשקי ה-API כמו ציר זמן ביתי (או עדכון ביתי) הוא להבין כיצד להחזיר את הישויות המקושרות או ההיררכיות (כלומר tweet → user
, tweet → media
, media → author
וכו'):
בוא נראה איך X מטפל בזה.
מוקדם יותר בסוג TimelineTweet
נעשה שימוש בתת-סוג Tweet
. בוא נראה איך זה נראה:
export type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; // <-- Here // ... }; }; }; }; type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; // <-- Here }; type TimelineItem = { entryId: string; sortIndex: string; content: { __typename: 'TimelineTimelineItem'; itemContent: TimelineTweet; // <-- Here // ... }; }; type TimelineTweet = { __typename: 'TimelineTweet'; tweet_results: { result: Tweet; // <-- Here }; }; // A Tweet entity type Tweet = { __typename: 'Tweet'; core: { user_results: { result: User; // <-- Here (a dependent User entity) }; }; legacy: { full_text: string; // ... entities: { // <-- Here (a dependent Media entities) media: Media[]; hashtags: Hashtag[]; urls: Url[]; user_mentions: UserMention[]; }; }; }; // A User entity type User = { __typename: 'User'; id: string; // 'VXNlcjoxNDUxM4ADSG44MTA4NDc4OTc2' // ... legacy: { location: string; // 'San Francisco' name: string; // 'John Doe' // ... }; }; // A Media entity type Media = { // ... source_user_id_str: string; // '1867041249938530657' <-- Here (the dependant user is being mentioned by its ID) url: string; // 'https://t.co/X78dBgtrsNU' features: { large: { faces: FaceGeometry[] }; medium: { faces: FaceGeometry[] }; small: { faces: FaceGeometry[] }; orig: { faces: FaceGeometry[] }; }; sizes: { large: MediaSize; medium: MediaSize; small: MediaSize; thumb: MediaSize; }; video_info: VideoInfo[]; };
מה שמעניין כאן הוא שרוב הנתונים התלויים כמו tweet → media
tweet → author
מוטבעים בתגובה בשיחה הראשונה (ללא שאילתות עוקבות).
כמו כן, חיבורי User
Media
עם ישויות Tweet
אינם מנורמלים (אם לשני ציוצים יש אותו מחבר, הנתונים שלהם יחזרו על עצמם בכל אובייקט ציוץ). אבל נראה שזה צריך להיות בסדר, שכן בהיקף של ציר הזמן הביתי עבור משתמש ספציפי, הציוצים יהיו מחברים על ידי מחברים רבים וחזרות אפשריות אך דלילות.
ההנחה שלי הייתה שה- UserTweets
API (שאנחנו לא מכסים כאן), שאחראי על שליפת הציוצים של משתמש מסוים יטפל בזה אחרת, אבל, כנראה, זה לא המקרה. ה- UserTweets
מחזיר את רשימת הציוצים של אותו משתמש ומטמיע את אותם נתוני משתמש שוב ושוב עבור כל ציוץ. זה מעניין. אולי הפשטות של הגישה גוברת על תקורה מסוימת של גודל נתונים (אולי נתוני משתמש נחשבים די קטנים בגודלם). אני לא בטוח.
התבוננות נוספת בנוגע ליחסי הגופים היא שלישות Media
יש גם קישור User
(המחבר). אבל היא לא עושה זאת באמצעות הטבעת ישויות ישירה כפי שעושה ישות Tweet
, אלא היא מקשרת דרך המאפיין Media.source_user_id_str
.
ה"תגובות" (שהן גם ה"ציוצים" מטבען) לכל "ציוץ" בציר הזמן הביתי אינן נשלפות כלל. כדי לראות את שרשור הציוץ על המשתמש ללחוץ על הציוץ כדי לראות את התצוגה המפורטת שלו. שרשור הציוץ יובא על ידי קריאה לנקודת הקצה TweetDetail
(עוד על כך בסעיף "דף פרטי ציוץ" למטה).
ישות נוספת שיש לכל Tweet
היא FeedbackActions
(כלומר "המליץ בתדירות נמוכה יותר" או "ראה פחות"). האופן שבו מאוחסנים FeedbackActions
באובייקט התגובה שונה מהאופן שבו מאוחסנים אובייקטי User
Media
. בעוד שישויות User
Media
הן חלק Tweet
, FeedbackActions
מאוחסנות בנפרד במערך TimelineItem.content.feedbackInfo.feedbackKeys
ומקושרות באמצעות ActionKey
. זו הייתה הפתעה קלה עבורי מכיוון שלא נראה כי כל פעולה ניתנת לשימוש חוזר. זה נראה כאילו נעשה שימוש בפעולה אחת עבור ציוץ מסוים בלבד. אז נראה שאפשר להטמיע את FeedbackActions
בכל ציוץ באותו אופן כמו ישויות Media
. אבל אולי חסרה לי כאן מורכבות נסתרת (כמו העובדה שלכל פעולה יכולות להיות פעולות ילדים).
פרטים נוספים על הפעולות נמצאים בסעיף "פעולות ציוץ" למטה.
סדר המיון של ערכי ציר הזמן מוגדר על ידי הקצה העורפי באמצעות מאפייני sortIndex
:
type TimelineCursor = { entryId: string; sortIndex: string; // '1866961576813152212' <-- Here content: { __typename: 'TimelineTimelineCursor'; value: string; cursorType: 'Top' | 'Bottom'; }; }; type TimelineItem = { entryId: string; sortIndex: string; // '1866561576636152411' <-- Here content: { __typename: 'TimelineTimelineItem'; itemContent: TimelineTweet; feedbackInfo: { feedbackKeys: ActionKey[]; }; }; }; type TimelineModule = { entryId: string; sortIndex: string; // '73343543020642838441' <-- Here content: { __typename: 'TimelineTimelineModule'; items: { entryId: string, item: TimelineTweet, }[], displayType: 'VerticalConversation', }; };
sortIndex
עצמו עשוי להיראות בערך כך '1867231621095096312'
. סביר להניח שהוא תואם ישירות או נגזר מא
למעשה, רוב המזהים שאתה רואה בתגובה (מזהים ציוצים) עוקבים אחר המוסכמה של "Snowflake ID" ונראים כמו '1867231621095096312'
.
אם זה משמש למיון ישויות כמו ציוצים, המערכת ממנפת את המיון הכרונולוגי המובנה של מזהי Snowflake. ציוצים או אובייקטים בעלי ערך sortIndex גבוה יותר (חותמת זמן עדכנית יותר) מופיעים גבוה יותר בפיד, בעוד שבעלי ערכים נמוכים יותר (חותמת זמן ישנה יותר) מופיעים נמוך יותר בפיד.
הנה הפענוח שלב אחר שלב של מזהה Snowflake (במקרה שלנו sortIndex
) 1867231621095096312
:
1867231621095096312 → 445182709954
445182709954 + 1288834974657 → 1734017684611ms
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[]; // <-- 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' }; };
מעניין כאן שמערך הפעולות השטוח הזה הוא בעצם עץ (או גרף? לא בדקתי), שכן לכל פעולה עשויות להיות פעולות צאצא (ראה מערך TimelineAction.value.childKeys
). זה הגיוני, למשל, כאשר לאחר שהמשתמש לוחץ על הפעולה "לא אוהב" , ההמשך עשוי להיות הצגת הפעולה "הפוסט הזה לא רלוונטי" , כדרך להסביר מדוע המשתמש עושה זאת. לא אוהב את הציוץ.
ברגע שהמשתמש ירצה לראות את דף הפרטים של הציוץ (כלומר לראות את שרשור ההערות/ציוצים), המשתמש לוחץ על הציוץ ומתבצעת בקשת GET
לנקודת הקצה הבאה:
GET https://x.com/i/api/graphql/{query-id}/TweetDetail?variables={"focalTweetId":"1867231621095096312","referrer":"home","controller_data":"DACABBSQ","rankingMode":"Relevance","includePromotedContent":true,"withCommunity":true}&features={"articles_preview_enabled":true}
הייתי סקרן כאן מדוע רשימת הציוצים נלקחת באמצעות שיחת POST
, אבל כל פרט ציוץ נלקח באמצעות קריאת ה- GET
. נראה לא עקבי. במיוחד יש לזכור שפרמטרים דומים של שאילתה כמו query-id
, features
ואחרים הפעם מועברים בכתובת האתר ולא בגוף הבקשה. גם פורמט התגובה דומה ועושה שימוש חוזר בסוגים מתוך שיחת הרשימה. אני לא בטוח למה זה. אבל שוב, אני בטוח שאולי חסרה כאן מורכבות רקע.
להלן סוגי גופי התגובה הפשוטים:
type TweetDetailResponse = { data: { threaded_conversation_with_injections_v2: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[], }, }, } type TimelineAddEntries = { type: 'TimelineAddEntries'; entries: (TimelineItem | TimelineCursor | TimelineModule)[]; }; type TimelineTerminateTimeline = { type: 'TimelineTerminateTimeline', direction: 'Top', } type TimelineModule = { entryId: string; // 'conversationthread-58668734545929871193' sortIndex: string; // '1867231621095096312' content: { __typename: 'TimelineTimelineModule'; items: { entryId: string, // 'conversationthread-1866876425669871193-tweet-1866876038930951193' item: TimelineTweet, }[], // Comments to the tweets are also tweets displayType: 'VerticalConversation', }; };
התגובה די דומה (בסוגיה) לתגובת הרשימה, אז לא נמשיך כאן יותר מדי.
ניואנס מעניין אחד הוא שה"הערות" (או השיחות) של כל ציוץ הן למעשה ציוצים אחרים (ראה סוג TimelineModule
). אז שרשור הציוץ נראה דומה מאוד לעדכון ציר הזמן הביתי על ידי הצגת רשימת הערכים של TimelineTweet
. זה נראה אלגנטי. דוגמה טובה לגישה אוניברסלית וניתנת לשימוש חוזר לעיצוב ה-API.
כאשר משתמש אוהב את הציוץ, בקשת ה- POST
לנקודת הקצה הבאה מתבצעת:
POST https://x.com/i/api/graphql/{query-id}/FavoriteTweet
להלן סוגי גוף הבקשה :
type FavoriteTweetRequest = { variables: { tweet_id: string; // '1867041249938530657' }; queryId: string; // 'lI07N61twFgted2EgXILM7A' };
להלן סוגי גוף התגובה :
type FavoriteTweetResponse = { data: { favorite_tweet: 'Done', } }
נראה פשוט וגם דומה לגישה דמוית RPC לעיצוב ה-API.
נגענו בחלקים בסיסיים של עיצוב ה-API של ציר הזמן הביתי על ידי התבוננות בדוגמה של ה-API של X. עשיתי כמה הנחות בדרך למיטב ידיעתי. אני מאמין שכמה דברים שאולי פירשתי לא נכון ואולי פיספסתי כמה ניואנסים מורכבים. אבל גם עם זה בחשבון, אני מקווה שקיבלת כמה תובנות שימושיות מהסקירה הכללית הזו ברמה גבוהה, משהו שתוכל ליישם בפגישת עיצוב API הבאה שלך.
בתחילה, הייתה לי תוכנית לעבור דרך אתרים דומים של טכנולוגיה מובילה כדי לקבל כמה תובנות מפייסבוק, Reddit, YouTube ואחרים ולאסוף שיטות עבודה ופתרונות מומלצים שנבדקו בקרב. אני לא בטוח אם אמצא את הזמן לעשות את זה. יראה. אבל זה יכול להיות תרגיל מעניין.
לעיון, אני מוסיף כאן את כל הסוגים במכה אחת. אתה יכול גם למצוא את כל הסוגים ב
/** * 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;