Όταν πρόκειται για το σχεδιασμό του API του συστήματος, οι μηχανικοί λογισμικού συχνά εξετάζουν διαφορετικές επιλογές όπως
Σε αυτό το άρθρο, διερευνούμε πώς έχει σχεδιαστεί το API αρχικής γραμμής χρόνου X ( Twitter ) (x.com/home) και ποιες προσεγγίσεις χρησιμοποιούν για την επίλυση των ακόλουθων προκλήσεων:
Πώς να ανακτήσετε τη λίστα των tweets
Πώς να κάνετε μια ταξινόμηση και σελιδοποίηση
Πώς να επιστρέψετε τις ιεραρχικές/συνδεδεμένες οντότητες (tweets, χρήστες, μέσα)
Πώς να λάβετε λεπτομέρειες tweet
Πώς να κάνετε "like" σε ένα tweet
Θα διερευνήσουμε αυτές τις προκλήσεις μόνο σε επίπεδο API, αντιμετωπίζοντας την υλοποίηση του backend ως μαύρο κουτί, καθώς δεν έχουμε πρόσβαση στον ίδιο τον κώδικα υποστήριξης.
Η εμφάνιση των ακριβών αιτημάτων και απαντήσεων εδώ μπορεί να είναι δυσκίνητη και δύσκολη, καθώς τα βαθιά ένθετα και επαναλαμβανόμενα αντικείμενα είναι δύσκολο να διαβαστούν. Για να διευκολυνθεί η προβολή της δομής ωφέλιμου φορτίου αιτήματος/απόκρισης, προσπάθησα να "πληκτρολογήσω" το API αρχικής γραμμής χρόνου στο TypeScript. Έτσι, όταν πρόκειται για τα παραδείγματα αιτήματος/απόκρισης, θα χρησιμοποιήσω τους τύπους αιτήματος και απόκρισης αντί για πραγματικά αντικείμενα JSON. Επίσης, να θυμάστε ότι οι τύποι είναι απλοποιημένοι και πολλές ιδιότητες παραλείπονται για συντομία.
Μπορείτε να βρείτε όλους τους τύπους σε
Η ανάκτηση της λίστας των tweet για το αρχικό χρονοδιάγραμμα ξεκινά με το αίτημα 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
της διεύθυνσης URL υποδεικνύει ότι ο 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 # ... } # ... } }
Η υπόθεση εδώ είναι ότι το homeline API δεν είναι ένα καθαρό GraphQL API, αλλά είναι ένας συνδυασμός πολλών προσεγγίσεων . Η μετάδοση των παραμέτρων σε ένα αίτημα POST όπως αυτό φαίνεται πιο κοντά στη "λειτουργική" κλήση RPC. Αλλά ταυτόχρονα, φαίνεται ότι οι δυνατότητες του GraphQL μπορεί να χρησιμοποιούνται κάπου στο backend πίσω από τον χειριστή/ελεγκτή τελικού σημείου HomeTimeline . Ένας συνδυασμός όπως αυτός μπορεί επίσης να προκαλείται από έναν κωδικό παλαιού τύπου ή από κάποιο είδος συνεχιζόμενης μετεγκατάστασης. Αλλά και πάλι, αυτές είναι μόνο οι εικασίες μου.
Μπορεί επίσης να παρατηρήσετε ότι το ίδιο TimelineRequest.queryId
χρησιμοποιείται στη διεύθυνση URL του API καθώς και στο σώμα αιτήματος API. Αυτό το queryId πιθανότατα δημιουργείται στο backend, μετά ενσωματώνεται στο main.js
bundle και, στη συνέχεια, χρησιμοποιείται κατά την ανάκτηση των δεδομένων από το backend. Μου είναι δύσκολο να καταλάβω πώς ακριβώς χρησιμοποιείται αυτό queryId
, καθώς το backend του X είναι ένα μαύρο κουτί στην περίπτωσή μας. Αλλά, και πάλι, η εικασία εδώ μπορεί να είναι ότι, μπορεί να απαιτείται για κάποιο είδος βελτιστοποίησης απόδοσης (επαναχρησιμοποίηση ορισμένων προ-υπολογισμένων αποτελεσμάτων ερωτημάτων;), προσωρινής αποθήκευσης (σχετικά με το Apollo;), εντοπισμού σφαλμάτων (συμμετοχή σε αρχεία καταγραφής μέσω queryId;), ή σκοπούς παρακολούθησης/ιχνηλασίας.
Είναι επίσης ενδιαφέρον να σημειωθεί ότι το TimelineResponse
δεν περιέχει μια λίστα με tweets, αλλά μάλλον μια λίστα οδηγιών , όπως "προσθήκη tweet στη γραμμή χρόνου" (δείτε τον τύπο 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
ορίζει πόσα tweets θέλουμε να ανακτήσουμε ταυτόχρονα (ανά σελίδα). Η προεπιλογή είναι 20. Ωστόσο, περισσότερα από 20 tweets μπορούν να επιστραφούν στον πίνακα TimelineAddEntries.entries
. Για παράδειγμα, ο πίνακας μπορεί να περιέχει 37 καταχωρήσεις για τη φόρτωση της πρώτης σελίδας, επειδή περιλαμβάνει tweets (29), καρφιτσωμένα tweets (1), προωθημένα tweets (5) και δρομείς σελιδοποίησης (2). Δεν είμαι σίγουρος γιατί υπάρχουν 29 κανονικά tweets με το ζητούμενο πλήθος των 20.
Ο TimelineRequest.variables.cursor
είναι υπεύθυνος για τη σελιδοποίηση που βασίζεται στον δρομέα.
" Η σελιδοποίηση δρομέα χρησιμοποιείται συχνότερα για δεδομένα σε πραγματικό χρόνο λόγω της συχνότητας που προστίθενται νέες εγγραφές και επειδή κατά την ανάγνωση των δεδομένων συχνά βλέπετε πρώτα τα πιο πρόσφατα αποτελέσματα. Εξαλείφει την πιθανότητα παράβλεψης στοιχείων και εμφάνισης του ίδιου στοιχείου περισσότερες από μία φορές. σελιδοποίηση με βάση τον δρομέα, ένας σταθερός δείκτης (ή δρομέας) χρησιμοποιείται για να παρακολουθείτε από πού στο σύνολο δεδομένων πρέπει να ληφθούν τα επόμενα στοιχεία." Δείτε το
Κατά τη λήψη της λίστας των tweet για πρώτη φορά, ο TimelineRequest.variables.cursor
είναι κενός, καθώς θέλουμε να φέρουμε τα κορυφαία tweet από την προεπιλεγμένη (πιθανότατα προυπολογισμένη) λίστα εξατομικευμένων tweet.
Ωστόσο, στην απόκριση, μαζί με τα δεδομένα του tweet, το 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'; }; };
Κάθε σελίδα περιέχει τη λίστα των tweets μαζί με τους δρομείς "πάνω" και "κάτω":
Αφού φορτωθούν τα δεδομένα της σελίδας, μπορούμε να πάμε από την τρέχουσα σελίδα και προς τις δύο κατευθύνσεις και να φέρουμε είτε τα "προηγούμενα/παλαιότερα" tweet χρησιμοποιώντας τον "κάτω" κέρσορα ή τα "επόμενα/νεότερα" tweets χρησιμοποιώντας τον κέρσορα "επάνω". Η υπόθεσή μου είναι ότι η ανάκτηση των "επόμενων" tweet χρησιμοποιώντας τον κέρσορα "επάνω" συμβαίνει σε δύο περιπτώσεις: όταν τα νέα tweets προστέθηκαν ενώ ο χρήστης εξακολουθεί να διαβάζει την τρέχουσα σελίδα ή όταν ο χρήστης αρχίζει να κάνει κύλιση στη ροή προς τα πάνω (και υπάρχουν δεν υπάρχουν καταχωρήσεις προσωρινής αποθήκευσης ή εάν οι προηγούμενες καταχωρήσεις διαγράφηκαν για λόγους απόδοσης).
Ο ίδιος ο κέρσορας του X μπορεί να μοιάζει με αυτό: DAABCgABGemI6Mk__9sKAAIZ6MSYG9fQGwgAAwAAAAIAAA
. Σε ορισμένα σχέδια API, ο δρομέας μπορεί να είναι μια κωδικοποιημένη συμβολοσειρά Base64 που περιέχει το αναγνωριστικό της τελευταίας καταχώρισης στη λίστα ή τη χρονική σήμανση της τελευταίας καταχώρισης που εμφανίστηκε. Για παράδειγμα: eyJpZCI6ICIxMjM0NTY3ODkwIn0= --> {"id": "1234567890"}
και, στη συνέχεια, αυτά τα δεδομένα χρησιμοποιούνται για την ανάλογη αναζήτηση στη βάση δεδομένων. Στην περίπτωση του X API, φαίνεται ότι ο κέρσορας αποκωδικοποιείται από το Base64 σε κάποια προσαρμοσμένη δυαδική ακολουθία που μπορεί να απαιτήσει κάποια περαιτέρω αποκωδικοποίηση για να εξαχθεί οποιοδήποτε νόημα από αυτό (δηλαδή μέσω των ορισμών του μηνύματος Protobuf). Δεδομένου ότι δεν γνωρίζουμε αν είναι κωδικοποίηση .proto
και επίσης δεν γνωρίζουμε τον ορισμό του μηνύματος .proto
μπορούμε απλώς να υποθέσουμε ότι το backend ξέρει πώς να υποβάλει ερώτημα για την επόμενη παρτίδα tweet με βάση τη συμβολοσειρά του δρομέα.
Η παράμετρος TimelineResponse.variables.seenTweetIds
χρησιμοποιείται για να ενημερώσει τον διακομιστή σχετικά με ποια tweets από την τρέχουσα ενεργή σελίδα της άπειρης κύλισης έχει ήδη δει ο πελάτης. Αυτό πιθανότατα βοηθά να διασφαλιστεί ότι ο διακομιστής δεν περιλαμβάνει διπλότυπα tweets στις επόμενες σελίδες αποτελεσμάτων.
Μία από τις προκλήσεις που πρέπει να επιλυθούν στα API, όπως το αρχικό χρονοδιάγραμμα (ή το Home Feed) είναι να καταλάβετε πώς να επιστρέψετε τις συνδεδεμένες ή ιεραρχικές οντότητες (π.χ. tweet → user
, tweet → media
, media → author
, κ.λπ.):
Ας δούμε πώς το χειρίζεται ο Χ.
Νωρίτερα στον τύπο 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
δεν κανονικοποιούνται (αν δύο tweets έχουν τον ίδιο συγγραφέα, τα δεδομένα τους θα επαναλαμβάνονται σε κάθε αντικείμενο tweet). Αλλά φαίνεται ότι θα έπρεπε να είναι εντάξει, αφού στο πλαίσιο του αρχικού χρονοδιαγράμματος για έναν συγκεκριμένο χρήστη τα tweets θα συντάσσονται από πολλούς συντάκτες και οι επαναλήψεις είναι πιθανές αλλά αραιές.
Η υπόθεσή μου ήταν ότι το UserTweets
API (που δεν καλύπτουμε εδώ), το οποίο είναι υπεύθυνο για την ανάκτηση των tweets ενός συγκεκριμένου χρήστη θα το χειριστεί διαφορετικά, αλλά, προφανώς, δεν ισχύει. Το UserTweets
επιστρέφει τη λίστα των tweet του ίδιου χρήστη και ενσωματώνει τα ίδια δεδομένα χρήστη ξανά και ξανά για κάθε tweet. Είναι ενδιαφέρον. Ίσως η απλότητα της προσέγγισης ξεπερνάει κάποιο μέγεθος δεδομένων (ίσως τα δεδομένα χρήστη να θεωρούνται αρκετά μικρά σε μέγεθος). Δεν είμαι σίγουρος.
Μια άλλη παρατήρηση σχετικά με τη σχέση των οντοτήτων είναι ότι η οντότητα Media
έχει επίσης έναν σύνδεσμο με τον User
(τον συγγραφέα). Αλλά το κάνει όχι μέσω άμεσης ενσωμάτωσης οντοτήτων όπως κάνει η οντότητα Tweet
, αλλά μάλλον συνδέεται μέσω της ιδιότητας Media.source_user_id_str
.
Τα "σχόλια" (που είναι και τα "tweets" από τη φύση τους) για κάθε "tweet" στο χρονοδιάγραμμα της αρχικής σελίδας δεν λαμβάνονται καθόλου. Για να δει το νήμα του tweet, ο χρήστης πρέπει να κάνει κλικ στο tweet για να δει τη λεπτομερή προβολή του. Το νήμα του tweet θα ληφθεί καλώντας το τελικό σημείο TweetDetail
(περισσότερα σχετικά στην ενότητα "Σελίδα λεπτομερειών Tweet" παρακάτω).
Μια άλλη οντότητα που έχει κάθε Tweet
είναι FeedbackActions
(π.χ. "Συστήνεται λιγότερο συχνά" ή "Δείτε λιγότερα"). Ο τρόπος αποθήκευσης των FeedbackActions
στο αντικείμενο απόκρισης είναι διαφορετικός από τον τρόπο αποθήκευσης των αντικειμένων User
και Media
. Ενώ οι οντότητες User
και Media
αποτελούν μέρος του Tweet
, οι FeedbackActions
αποθηκεύονται χωριστά στον πίνακα TimelineItem.content.feedbackInfo.feedbackKeys
και συνδέονται μέσω του ActionKey
. Αυτό ήταν μια μικρή έκπληξη για μένα, καθώς δεν φαίνεται να ισχύει ότι οποιαδήποτε ενέργεια μπορεί να επαναχρησιμοποιηθεί. Φαίνεται ότι μια ενέργεια χρησιμοποιείται μόνο για ένα συγκεκριμένο tweet. Φαίνεται λοιπόν ότι τα FeedbackActions
θα μπορούσαν να ενσωματωθούν σε κάθε tweet με τον ίδιο τρόπο όπως οι οντότητες Media
. Αλλά μπορεί να μου λείπει κάποια κρυμμένη πολυπλοκότητα εδώ (όπως το γεγονός ότι κάθε δράση μπορεί να έχει παιδικές ενέργειες).
Περισσότερες λεπτομέρειες σχετικά με τις ενέργειες βρίσκονται στην ενότητα "Ενέργειες Tweet" παρακάτω.
Η σειρά ταξινόμησης των καταχωρήσεων της γραμμής χρόνου ορίζεται από το backend μέσω των ιδιοτήτων 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'
. Πιθανότατα αντιστοιχεί άμεσα ή προέρχεται από το α
Στην πραγματικότητα, τα περισσότερα από τα αναγνωριστικά που βλέπετε στην απάντηση (αναγνωριστικά tweet) ακολουθούν τη σύμβαση "Snowflake ID" και μοιάζουν με '1867231621095096312'
.
Εάν αυτό χρησιμοποιείται για την ταξινόμηση οντοτήτων όπως τα tweets, το σύστημα αξιοποιεί την εγγενή χρονολογική ταξινόμηση των αναγνωριστικών Snowflake. Τα tweets ή τα αντικείμενα με υψηλότερη τιμή sortIndex (πιο πρόσφατη χρονική σήμανση) εμφανίζονται υψηλότερα στη ροή, ενώ αυτά με χαμηλότερες τιμές (παλαιότερη χρονική σήμανση) εμφανίζονται χαμηλότερα στη ροή.
Ακολουθεί η αποκωδικοποίηση βήμα προς βήμα του Snowflake ID (στην περίπτωσή μας το sortIndex
) 1867231621095096312
:
1867231621095096312 → 445182709954
445182709954 + 1288834974657 → 1734017684611ms
1734017684611ms → 2024-12-12 15:34:44.611 (UTC)
Μπορούμε λοιπόν να υποθέσουμε εδώ ότι τα tweets στο homeline είναι ταξινομημένα χρονολογικά.
Κάθε tweet έχει ένα μενού "Ενέργειες".
Οι ενέργειες για κάθε tweet προέρχονται από το backend σε έναν πίνακα TimelineItem.content.feedbackInfo.feedbackKeys
και συνδέονται με τα tweet μέσω του 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
). Αυτό είναι λογικό, για παράδειγμα, όταν ο χρήστης κάνει κλικ στην ενέργεια "Δεν μου αρέσει" , η συνέχεια μπορεί να είναι να εμφανιστεί η ενέργεια "Αυτή η ανάρτηση δεν είναι σχετική" , ως ένας τρόπος να εξηγήσει γιατί ο χρήστης Δεν μου αρέσει το tweet.
Μόλις ο χρήστης θέλει να δει τη σελίδα λεπτομερειών του tweet (δηλαδή για να δει το νήμα των σχολίων/tweet), ο χρήστης κάνει κλικ στο tweet και εκτελείται το αίτημα 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}
Ήμουν περίεργος εδώ γιατί λαμβάνεται η λίστα των tweet μέσω της κλήσης POST
, αλλά κάθε λεπτομέρεια του tweet λαμβάνεται μέσω της κλήσης GET
. Φαίνεται ασυνεπής. Ειδικά λαμβάνοντας υπόψη ότι παρόμοιες παράμετροι ερωτήματος, όπως query-id
, features
και άλλες, αυτή τη φορά μεταβιβάζονται στη διεύθυνση URL και όχι στο σώμα του αιτήματος. Η μορφή απόκρισης είναι επίσης παρόμοια και χρησιμοποιεί ξανά τους τύπους από την κλήση λίστας. Δεν είμαι σίγουρος γιατί είναι αυτό. Αλλά και πάλι, είμαι βέβαιος ότι μπορεί να μου λείπει κάποια πολυπλοκότητα του φόντου εδώ.
Ακολουθούν οι απλοποιημένοι τύποι σωμάτων απόκρισης:
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', }; };
Η απόκριση είναι αρκετά παρόμοια (στα είδη της) με την απόκριση της λίστας, οπότε δεν θα μείνουμε για πολύ εδώ.
Μια ενδιαφέρουσα απόχρωση είναι ότι τα "σχόλια" (ή οι συνομιλίες) κάθε tweet είναι στην πραγματικότητα άλλα tweets (δείτε τον τύπο TimelineModule
). Έτσι, το νήμα του tweet μοιάζει πολύ με τη ροή του χρονοδιαγράμματος αρχικής σελίδας, εμφανίζοντας τη λίστα με τις καταχωρήσεις TimelineTweet
. Αυτό φαίνεται κομψό. Ένα καλό παράδειγμα μιας καθολικής και επαναχρησιμοποιήσιμης προσέγγισης στο σχεδιασμό του API.
Όταν ένας χρήστης αρέσει στο tweet, εκτελείται το αίτημα 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.
Αρχικά, είχα ένα σχέδιο να περάσω από παρόμοιους ιστότοπους κορυφαίας τεχνολογίας για να λάβω κάποιες πληροφορίες από το Facebook, το 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;