paint-brush
Κατανόηση του API του Twitter ώστε να μπορείτε να σχεδιάσετε το δικό σαςμε@trekhleb
194 αναγνώσεις Νέα ιστορία

Κατανόηση του API του Twitter ώστε να μπορείτε να σχεδιάσετε το δικό σας

με Oleksii Trekhleb22m2024/12/16
Read on Terminal Reader

Πολύ μακρύ; Να διαβασω

Σε αυτό το άρθρο, διερευνούμε πώς έχει σχεδιαστεί το API αρχικής γραμμής χρόνου (x.com/home) του X (Twitter) και ποιες προσεγγίσεις χρησιμοποιούν για την επίλυση πολλαπλών προκλήσεων.
featured image - Κατανόηση του API του Twitter ώστε να μπορείτε να σχεδιάσετε το δικό σας
Oleksii Trekhleb HackerNoon profile picture
0-item
1-item

Όταν πρόκειται για το σχεδιασμό του API του συστήματος, οι μηχανικοί λογισμικού συχνά εξετάζουν διαφορετικές επιλογές όπως REST εναντίον RPC εναντίον GraphQL (ή άλλες υβριδικές προσεγγίσεις) για τον προσδιορισμό της καλύτερης προσαρμογής για μια συγκεκριμένη εργασία ή έργο.


Σε αυτό το άρθρο, διερευνούμε πώς έχει σχεδιαστεί το API αρχικής γραμμής χρόνου X ( Twitter ) (x.com/home) και ποιες προσεγγίσεις χρησιμοποιούν για την επίλυση των ακόλουθων προκλήσεων:

  • Πώς να ανακτήσετε τη λίστα των tweets

  • Πώς να κάνετε μια ταξινόμηση και σελιδοποίηση

  • Πώς να επιστρέψετε τις ιεραρχικές/συνδεδεμένες οντότητες (tweets, χρήστες, μέσα)

  • Πώς να λάβετε λεπτομέρειες tweet

  • Πώς να κάνετε "like" σε ένα tweet


Θα διερευνήσουμε αυτές τις προκλήσεις μόνο σε επίπεδο API, αντιμετωπίζοντας την υλοποίηση του backend ως μαύρο κουτί, καθώς δεν έχουμε πρόσβαση στον ίδιο τον κώδικα υποστήριξης.


Η εμφάνιση των ακριβών αιτημάτων και απαντήσεων εδώ μπορεί να είναι δυσκίνητη και δύσκολη, καθώς τα βαθιά ένθετα και επαναλαμβανόμενα αντικείμενα είναι δύσκολο να διαβαστούν. Για να διευκολυνθεί η προβολή της δομής ωφέλιμου φορτίου αιτήματος/απόκρισης, προσπάθησα να "πληκτρολογήσω" το API αρχικής γραμμής χρόνου στο TypeScript. Έτσι, όταν πρόκειται για τα παραδείγματα αιτήματος/απόκρισης, θα χρησιμοποιήσω τους τύπους αιτήματος και απόκρισης αντί για πραγματικά αντικείμενα JSON. Επίσης, να θυμάστε ότι οι τύποι είναι απλοποιημένοι και πολλές ιδιότητες παραλείπονται για συντομία.


Μπορείτε να βρείτε όλους τους τύπους σε τύπους/χ.τσ αρχείο ή στο κάτω μέρος αυτού του άρθρου στην ενότητα "Παράρτημα: Όλοι οι τύποι σε ένα μέρος".

Λήψη της λίστας των tweets

Το τελικό σημείο και η δομή αιτήματος/απόκρισης

Η ανάκτηση της λίστας των 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 του.


Χρησιμοποιώ τη λέξη "γεύση" εδώ επειδή το ίδιο το σώμα της αίτησης δεν μοιάζει με καθαρό Ερώτημα 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 # ... } # ... } }


Η υπόθεση εδώ είναι ότι το 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 μπορεί επίσης να περιέχει διαφορετικούς τύπους οντοτήτων:

  • Tweets — δείτε τον τύπο 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 , κ.λπ.):


  • Θα πρέπει πρώτα να επιστρέψουμε μόνο τη λίστα των tweet και στη συνέχεια να ανακτήσουμε τις εξαρτημένες οντότητες (όπως στοιχεία χρήστη) σε μια δέσμη ξεχωριστών ερωτημάτων κατ' απαίτηση;
  • Ή πρέπει να επιστρέψουμε όλα τα δεδομένα ταυτόχρονα, αυξάνοντας τον χρόνο και το μέγεθος της πρώτης φόρτωσης, αλλά εξοικονομώντας χρόνο για όλες τις επόμενες κλήσεις;
    • Χρειάζεται να κανονικοποιήσουμε τα δεδομένα σε αυτήν την περίπτωση για να μειώσουμε το μέγεθος του ωφέλιμου φορτίου (δηλαδή όταν ο ίδιος χρήστης είναι συγγραφέας πολλών tweet και θέλουμε να αποφύγουμε την επανάληψη των δεδομένων χρήστη ξανά και ξανά σε κάθε οντότητα tweet);
  • Ή θα πρέπει να είναι ένας συνδυασμός των παραπάνω προσεγγίσεων;


Ας δούμε πώς το χειρίζεται ο Χ.

Νωρίτερα στον τύπο 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 :

  • Εξαγάγετε τη χρονική σήμανση :
    • Η χρονική σήμανση προκύπτει μετατοπίζοντας δεξιά κατά 22 bit το Snowflake ID (για να αφαιρέσετε τα χαμηλότερα 22 bit για το κέντρο δεδομένων, το αναγνωριστικό εργαζόμενου και την ακολουθία): 1867231621095096312 → 445182709954
  • Προσθέστε την εποχή του Twitter :
    • Η προσθήκη της προσαρμοσμένης εποχής του Twitter (1288834974657) σε αυτήν τη χρονική σήμανση δίνει τη χρονική σήμανση UNIX σε χιλιοστά του δευτερολέπτου: 445182709954 + 1288834974657 → 1734017684611ms
  • Μετατροπή σε αναγνώσιμη από τον άνθρωπο ημερομηνία :
    • Η μετατροπή της χρονικής σφραγίδας UNIX σε ώρα ημερομηνίας UTC δίνει: 1734017684611ms → 2024-12-12 15:34:44.611 (UTC)


Μπορούμε λοιπόν να υποθέσουμε εδώ ότι τα tweets στο homeline είναι ταξινομημένα χρονολογικά.

Ενέργειες tweet

Κάθε 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), ο χρήστης κάνει κλικ στο 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

Όταν ένας χρήστης αρέσει στο 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;