diff --git a/bridges/TwitterBridge.php b/bridges/TwitterBridge.php index 36f67defabb..31b97c86300 100644 --- a/bridges/TwitterBridge.php +++ b/bridges/TwitterBridge.php @@ -123,7 +123,7 @@ class TwitterBridge extends BridgeAbstract private $apiKey = null; private $guestToken = null; - private $authHeader = []; + private $authHeaders = []; public function detectParameters($url) { @@ -219,25 +219,23 @@ public function collectData() $tweets = []; // Get authentication information - $this->getApiKey(); // Try to get all tweets switch ($this->queriedContext) { case 'By username': - $user = $this->makeApiCall('/1.1/users/show.json', ['screen_name' => $this->getInput('u')]); - if (!$user) { - returnServerError('Requested username can\'t be found.'); - } + $cacheFactory = new CacheFactory(); + $cache = $cacheFactory->create(); - $params = [ - 'user_id' => $user->id_str, - 'tweet_mode' => 'extended' - ]; + $cache->setScope('twitter'); + $cache->setKey(['cache']); + $cache->purgeCache(60 * 60 * 3); // 3h + $api = new TwitterClient($cache); - $data = $this->makeApiCall('/1.1/statuses/user_timeline.json', $params); + $data = $api->fetchUserTweets($this->getInput('u')); break; case 'By keyword or hashtag': + // Does not work with the recent twitter changes $params = [ 'q' => urlencode($this->getInput('q')), 'tweet_mode' => 'extended', @@ -248,6 +246,7 @@ public function collectData() break; case 'By list': + // Does not work with the recent twitter changes $params = [ 'slug' => strtolower($this->getInput('list')), 'owner_screen_name' => strtolower($this->getInput('user')), @@ -258,6 +257,7 @@ public function collectData() break; case 'By list ID': + // Does not work with the recent twitter changes $params = [ 'list_id' => $this->getInput('listid'), 'tweet_mode' => 'extended', @@ -284,7 +284,10 @@ public function collectData() } // Filter out unwanted tweets - foreach ($data as $tweet) { + foreach ($data->tweets as $tweet) { + if (!$tweet) { + continue; + } // Filter out retweets to remove possible duplicates of original tweet switch ($this->queriedContext) { case 'By keyword or hashtag': @@ -333,9 +336,9 @@ public function collectData() $realtweet = $tweet->retweeted_status; } - $item['username'] = $realtweet->user->screen_name; - $item['fullname'] = $realtweet->user->name; - $item['avatar'] = $realtweet->user->profile_image_url_https; + $item['username'] = $data->user_info->legacy->screen_name; + $item['fullname'] = $data->user_info->legacy->name; + $item['avatar'] = $data->user_info->legacy->profile_image_url_https; $item['timestamp'] = $realtweet->created_at; $item['id'] = $realtweet->id_str; $item['uri'] = self::URI . $item['username'] . '/status/' . $item['id']; diff --git a/lib/TwitterClient.php b/lib/TwitterClient.php new file mode 100644 index 00000000000..fa8d765f837 --- /dev/null +++ b/lib/TwitterClient.php @@ -0,0 +1,168 @@ +cache = $cache; + $this->authorization = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; + $this->data = $cache->loadData() ?? []; + } + + public function fetchUserTweets(string $screenName): \stdClass + { + $this->fetchGuestToken(); + try { + $userInfo = $this->fetchUserInfoByScreenName($screenName); + } catch (HttpException $e) { + if ($e->getCode() === 403) { + Logger::info('The guest token has expired'); + $this->data['guest_token'] = null; + $this->fetchGuestToken(); + $userInfo = $this->fetchUserInfoByScreenName($screenName); + } else { + throw $e; + } + } + + try { + $timeline = $this->fetchTimeline($userInfo->rest_id); + } catch (HttpException $e) { + if ($e->getCode() === 403) { + Logger::info('The guest token has expired'); + $this->data['guest_token'] = null; + $this->fetchGuestToken(); + $timeline = $this->fetchTimeline($userInfo->rest_id); + } else { + throw $e; + } + } + + $result = $timeline->data->user->result; + if ($result->__typename === 'UserUnavailable') { + throw new \Exception('UserUnavailable'); + } + $instructionTypes = ['TimelineAddEntries', 'TimelineClearCache']; + $instructions = $result->timeline_v2->timeline->instructions; + if (!isset($instructions[1])) { + throw new \Exception('The account exists but has not tweeted yet?'); + } + $instruction = $instructions[1]; + if ($instruction->type !== 'TimelineAddEntries') { + throw new \Exception(sprintf('Unexpected instruction type: %s', $instruction->type)); + } + $tweets = []; + foreach ($instruction->entries as $entry) { + if ($entry->content->entryType !== 'TimelineTimelineItem') { + continue; + } + if (!isset($entry->content->itemContent->tweet_results->result->legacy)) { + continue; + } + $tweets[] = $entry->content->itemContent->tweet_results->result->legacy; + } + return (object) [ + 'user_info' => $userInfo, + 'tweets' => $tweets, + ]; + } + + private function fetchGuestToken(): void + { + if (isset($this->data['guest_token'])) { + Logger::info('Reusing cached guest token: ' . $this->data['guest_token']); + return; + } + $url = 'https://api.twitter.com/1.1/guest/activate.json'; + $response = getContents($url, $this->createHttpHeaders(), [CURLOPT_POST => true]); + $guest_token = json_decode($response)->guest_token; + $this->data['guest_token'] = $guest_token; + $this->cache->saveData($this->data); + Logger::info("Fetch new guest token: $guest_token"); + } + + private function fetchUserInfoByScreenName(string $screenName) + { + if (isset($this->data[$screenName])) { + return $this->data[$screenName]; + } + $variables = [ + 'screen_name' => $screenName, + 'withHighlightedLabel' => true + ]; + $url = sprintf( + 'https://twitter.com/i/api/graphql/hc-pka9A7gyS3xODIafnrQ/UserByScreenName?variables=%s', + urlencode(json_encode($variables)) + ); + $response = json_decode(getContents($url, $this->createHttpHeaders())); + if (isset($response->errors)) { + // Grab the first error message + throw new \Exception(sprintf('From twitter api: "%s"', $response->errors[0]->message)); + } + $userInfo = $response->data->user; + $this->data[$screenName] = $userInfo; + $this->cache->saveData($this->data); + return $userInfo; + } + + private function fetchTimeline($userId) + { + $variables = [ + 'userId' => $userId, + 'count' => 40, + 'includePromotedContent' => true, + 'withQuickPromoteEligibilityTweetFields' => true, + 'withSuperFollowsUserFields' => true, + 'withDownvotePerspective' => false, + 'withReactionsMetadata' => false, + 'withReactionsPerspective' => false, + 'withSuperFollowsTweetFields' => true, + 'withVoice' => true, + 'withV2Timeline' => true, + ]; + $features = [ + 'responsive_web_twitter_blue_verified_badge_is_enabled' => true, + 'responsive_web_graphql_exclude_directive_enabled' => false, + 'verified_phone_label_enabled' => false, + 'responsive_web_graphql_timeline_navigation_enabled' => true, + 'responsive_web_graphql_skip_user_profile_image_extensions_enabled' => false, + 'longform_notetweets_consumption_enabled' => true, + 'tweetypie_unmention_optimization_enabled' => true, + 'vibe_api_enabled' => true, + 'responsive_web_edit_tweet_api_enabled' => true, + 'graphql_is_translatable_rweb_tweet_is_translatable_enabled' => true, + 'view_counts_everywhere_api_enabled' => true, + 'freedom_of_speech_not_reach_appeal_label_enabled' => false, + 'standardized_nudges_misinfo' => true, + 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled' => false, + 'interactive_text_enabled' => true, + 'responsive_web_text_conversations_enabled' => false, + 'responsive_web_enhance_cards_enabled' => false, + ]; + $url = sprintf( + 'https://twitter.com/i/api/graphql/WZT7sCTrLvSOaWOXLDsWbQ/UserTweets?variables=%s&features=%s', + urlencode(json_encode($variables)), + urlencode(json_encode($features)) + ); + $response = json_decode(getContents($url, $this->createHttpHeaders())); + return $response; + } + + private function createHttpHeaders(): array + { + $headers = [ + 'authorization' => sprintf('Bearer %s', $this->authorization), + 'x-guest-token' => $this->data['guest_token'] ?? null, + ]; + foreach ($headers as $key => $value) { + $headers[] = sprintf('%s: %s', $key, $value); + } + return $headers; + } +}