paint-brush
הבנת ממשק API של Twitter כך שתוכל לעצב בעצמךעל ידי@trekhleb
194 קריאות היסטוריה חדשה

הבנת ממשק API של Twitter כך שתוכל לעצב בעצמך

על ידי Oleksii Trekhleb22m2024/12/16
Read on Terminal Reader

יותר מדי זמן; לקרוא

במאמר זה, אנו חוקרים כיצד תוכנן ה-API של X (Twitter) הביתי (x.com/home) ובאילו גישות הם משתמשים כדי לפתור אתגרים מרובים.
featured image - הבנת ממשק API של Twitter כך שתוכל לעצב בעצמך
Oleksii Trekhleb HackerNoon profile picture
0-item
1-item

כשזה מגיע לעיצוב ה-API של המערכת, מהנדסי התוכנה שוקלים לעתים קרובות אפשרויות שונות כמו REST לעומת RPC מול GraphQL (או גישות היברידיות אחרות) כדי לקבוע את ההתאמה הטובה ביותר למשימה או פרויקט ספציפיים.


במאמר זה, אנו חוקרים כיצד תוכנן ה-API של X ( Twitter ) הביתי (x.com/home) ובאילו גישות הם משתמשים כדי לפתור את האתגרים הבאים:

  • כיצד להביא את רשימת הציוצים

  • איך עושים מיון ועימוד

  • כיצד להחזיר את הישויות ההיררכיות/מקושרות (ציוצים, משתמשים, מדיה)

  • כיצד לקבל פרטי ציוץ

  • איך לעשות "לייק" לציוץ


אנו נחקור את האתגרים הללו רק ברמת ה-API, נתייחס ליישום ה-backend כאל קופסה שחורה, מכיוון שאין לנו גישה לקוד ה-backend עצמו.


הצגת הבקשות והתגובות המדויקות כאן עשויה להיות מסורבלת וקשה לעקוב מאחר שקשה לקרוא את האובייקטים המקוננים והחוזרים על עצמם. כדי שיהיה קל יותר לראות את מבנה עומס הבקשה/התגובה, עשיתי את הניסיון שלי "להקליד" את ה-API של ציר הזמן הביתי ב-TypeScript. אז כשזה מגיע לדוגמאות הבקשה/תגובה אני אשתמש בסוגי הבקשה והתגובות במקום באובייקטי JSON בפועל. כמו כן, זכור כי הסוגים מפושטים ומאפיינים רבים מושמטים לקיצור.


אתה עשוי למצוא את כל הסוגים ב types/x.ts קובץ או בתחתית מאמר זה בסעיף "נספח: כל הסוגים במקום אחד".

מביא את רשימת הציוצים

נקודת הקצה ומבנה הבקשה/תגובה

שליפת רשימת הציוצים עבור ציר הזמן הביתי מתחילה בבקשת 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 שלהם.


אני משתמש כאן במילה "טעם" כי גוף הבקשה עצמו לא נראה כמו טהור שאילתת GraphQL , שבו אנו עשויים לתאר את מבנה התגובה הנדרש, תוך פירוט כל המאפיינים שאנו רוצים להביא:


 # 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 :

  • חלץ את חותמת הזמן :
    • חותמת הזמן נגזרת על ידי הזזה ימינה של מזהה Snowflake ב-22 סיביות (כדי להסיר את 22 הסיביות התחתונות עבור מרכז נתונים, מזהה עובד ורצף): 1867231621095096312 → 445182709954
  • הוסף את העידן של טוויטר :
    • הוספת העידן המותאם אישית של טוויטר (1288834974657) לחותמת זמן זו מעניקה את חותמת הזמן של UNIX באלפיות שניות: 445182709954 + 1288834974657 → 1734017684611ms
  • המר לתאריך הניתן לקריאה אנושית :
    • המרת חותמת הזמן של UNIX לתאריך שעון UTC נותן: 1734017684611ms → 2024-12-12 15:34:44.611 (UTC)


אז אנחנו יכולים להניח כאן שהציוצים בציר הזמן הביתי ממוינים בצורה כרונולוגית.

פעולות ציוץ

לכל ציוץ יש תפריט "פעולות".


הפעולות עבור כל ציוץ מגיעות מהקצה האחורי במערך TimelineItem.content.feedbackInfo.feedbackKeys ומקושרות עם הציוצים באמצעות ActionKey :


 type TimelineResponse = { data: { home: { home_timeline_urt: { instructions: (TimelineAddEntries | TimelineTerminateTimeline)[]; responseObjects: { feedbackActions: TimelineAction[]; // <-- 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 ואחרים ולאסוף שיטות עבודה ופתרונות מומלצים שנבדקו בקרב. אני לא בטוח אם אמצא את הזמן לעשות את זה. יראה. אבל זה יכול להיות תרגיל מעניין.

נספח: כל הסוגים במקום אחד

לעיון, אני מוסיף כאן את כל הסוגים במכה אחת. אתה יכול גם למצוא את כל הסוגים ב types/x.ts קוֹבֶץ.


 /** * 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;