mirror of
https://github.com/RSS-Bridge/rss-bridge.git
synced 2024-12-25 13:48:21 +03:00
596 lines
21 KiB
PHP
596 lines
21 KiB
PHP
<?php
|
|
|
|
class TwitterBridge extends BridgeAbstract
|
|
{
|
|
const NAME = 'Twitter Bridge';
|
|
const URI = 'https://twitter.com/';
|
|
const API_URI = 'https://api.twitter.com';
|
|
const GUEST_TOKEN_USES = 100;
|
|
const GUEST_TOKEN_EXPIRY = 10800; // 3hrs
|
|
const CACHE_TIMEOUT = 60 * 15; // 15min
|
|
const DESCRIPTION = 'returns tweets';
|
|
const MAINTAINER = 'arnd-s';
|
|
const PARAMETERS = [
|
|
'global' => [
|
|
'nopic' => [
|
|
'name' => 'Hide profile pictures',
|
|
'type' => 'checkbox',
|
|
'title' => 'Activate to hide profile pictures in content'
|
|
],
|
|
'noimg' => [
|
|
'name' => 'Hide images in tweets',
|
|
'type' => 'checkbox',
|
|
'title' => 'Activate to hide images in tweets'
|
|
],
|
|
'noimgscaling' => [
|
|
'name' => 'Disable image scaling',
|
|
'type' => 'checkbox',
|
|
'title' => 'Activate to disable image scaling in tweets (keeps original image)'
|
|
]
|
|
],
|
|
'By keyword or hashtag' => [
|
|
'q' => [
|
|
'name' => 'Keyword or #hashtag',
|
|
'required' => true,
|
|
'exampleValue' => 'rss-bridge OR rssbridge',
|
|
'title' => <<<EOD
|
|
* To search for multiple words (must contain all of these words), put a space between them.
|
|
|
|
Example: `rss-bridge release`.
|
|
|
|
* To search for multiple words (contains any of these words), put "OR" between them.
|
|
|
|
Example: `rss-bridge OR rssbridge`.
|
|
|
|
* To search for an exact phrase (including whitespace), put double-quotes around them.
|
|
|
|
Example: `"rss-bridge release"`
|
|
|
|
* If you want to search for anything **but** a specific word, put a hyphen before it.
|
|
|
|
Example: `rss-bridge -release` (ignores "release")
|
|
|
|
* Of course, this also works for hashtags.
|
|
|
|
Example: `#rss-bridge OR #rssbridge`
|
|
|
|
* And you can combine them in any shape or form you like.
|
|
|
|
Example: `#rss-bridge OR #rssbridge -release`
|
|
EOD
|
|
]
|
|
],
|
|
'By username' => [
|
|
'u' => [
|
|
'name' => 'username',
|
|
'required' => true,
|
|
'exampleValue' => 'sebsauvage',
|
|
'title' => 'Insert a user name'
|
|
],
|
|
'norep' => [
|
|
'name' => 'Without replies',
|
|
'type' => 'checkbox',
|
|
'title' => 'Only return initial tweets'
|
|
],
|
|
'noretweet' => [
|
|
'name' => 'Without retweets',
|
|
'required' => false,
|
|
'type' => 'checkbox',
|
|
'title' => 'Hide retweets'
|
|
],
|
|
'nopinned' => [
|
|
'name' => 'Without pinned tweet',
|
|
'required' => false,
|
|
'type' => 'checkbox',
|
|
'title' => 'Hide pinned tweet'
|
|
]
|
|
],
|
|
'By list' => [
|
|
'user' => [
|
|
'name' => 'User',
|
|
'required' => true,
|
|
'exampleValue' => 'Scobleizer',
|
|
'title' => 'Insert a user name'
|
|
],
|
|
'list' => [
|
|
'name' => 'List',
|
|
'required' => true,
|
|
'exampleValue' => 'Tech-News',
|
|
'title' => 'Insert the list name'
|
|
],
|
|
'filter' => [
|
|
'name' => 'Filter',
|
|
'exampleValue' => '#rss-bridge',
|
|
'required' => false,
|
|
'title' => 'Specify term to search for'
|
|
]
|
|
],
|
|
'By list ID' => [
|
|
'listid' => [
|
|
'name' => 'List ID',
|
|
'exampleValue' => '31748',
|
|
'required' => true,
|
|
'title' => 'Insert the list id'
|
|
],
|
|
'filter' => [
|
|
'name' => 'Filter',
|
|
'exampleValue' => '#rss-bridge',
|
|
'required' => false,
|
|
'title' => 'Specify term to search for'
|
|
]
|
|
]
|
|
];
|
|
|
|
private $apiKey = null;
|
|
private $guestToken = null;
|
|
private $authHeaders = [];
|
|
private ?string $feedIconUrl = null;
|
|
|
|
public function detectParameters($url)
|
|
{
|
|
$params = [];
|
|
|
|
// By keyword or hashtag (search)
|
|
$regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/search.*(\?|&)q=([^\/&?\n]+)/';
|
|
if (preg_match($regex, $url, $matches) > 0) {
|
|
$params['context'] = 'By keyword or hashtag';
|
|
$params['q'] = urldecode($matches[4]);
|
|
return $params;
|
|
}
|
|
|
|
// By hashtag
|
|
$regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/hashtag\/([^\/?\n]+)/';
|
|
if (preg_match($regex, $url, $matches) > 0) {
|
|
$params['context'] = 'By keyword or hashtag';
|
|
$params['q'] = urldecode($matches[3]);
|
|
return $params;
|
|
}
|
|
|
|
// By list
|
|
$regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/([^\/?\n]+)\/lists\/([^\/?\n]+)/';
|
|
if (preg_match($regex, $url, $matches) > 0) {
|
|
$params['context'] = 'By list';
|
|
$params['user'] = urldecode($matches[3]);
|
|
$params['list'] = urldecode($matches[4]);
|
|
return $params;
|
|
}
|
|
|
|
// By username
|
|
$regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/([^\/?\n]+)/';
|
|
if (preg_match($regex, $url, $matches) > 0) {
|
|
$params['context'] = 'By username';
|
|
$params['u'] = urldecode($matches[3]);
|
|
return $params;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function getName()
|
|
{
|
|
switch ($this->queriedContext) {
|
|
case 'By keyword or hashtag':
|
|
$specific = 'search ';
|
|
$param = 'q';
|
|
break;
|
|
case 'By username':
|
|
$specific = '@';
|
|
$param = 'u';
|
|
break;
|
|
case 'By list':
|
|
return $this->getInput('list') . ' - Twitter list by ' . $this->getInput('user');
|
|
case 'By list ID':
|
|
return 'Twitter List #' . $this->getInput('listid');
|
|
default:
|
|
return parent::getName();
|
|
}
|
|
return 'Twitter ' . $specific . $this->getInput($param);
|
|
}
|
|
|
|
public function getURI()
|
|
{
|
|
switch ($this->queriedContext) {
|
|
case 'By keyword or hashtag':
|
|
return self::URI
|
|
. 'search?q='
|
|
. urlencode($this->getInput('q'))
|
|
. '&f=tweets';
|
|
case 'By username':
|
|
return self::URI
|
|
. urlencode($this->getInput('u'));
|
|
// Always return without replies!
|
|
// . ($this->getInput('norep') ? '' : '/with_replies');
|
|
case 'By list':
|
|
return self::URI
|
|
. urlencode($this->getInput('user'))
|
|
. '/lists/'
|
|
. str_replace(' ', '-', strtolower($this->getInput('list')));
|
|
case 'By list ID':
|
|
return self::URI
|
|
. 'i/lists/'
|
|
. urlencode($this->getInput('listid'));
|
|
default:
|
|
return parent::getURI();
|
|
}
|
|
}
|
|
|
|
private function getFullText($id)
|
|
{
|
|
$url = sprintf(
|
|
'https://cdn.syndication.twimg.com/tweet-result?id=%s&lang=en&token=449yf2pc4g',
|
|
$id
|
|
);
|
|
|
|
return json_decode(getContents($url), false);
|
|
}
|
|
|
|
public function collectData()
|
|
{
|
|
// $data will contain an array of all found tweets (unfiltered)
|
|
$data = null;
|
|
// Contains user data (when in by username context)
|
|
$user = null;
|
|
// Array of all found tweets
|
|
$tweets = [];
|
|
|
|
// Get authentication information
|
|
$api = new TwitterClient($this->cache);
|
|
// Try to get all tweets
|
|
switch ($this->queriedContext) {
|
|
case 'By username':
|
|
$screenName = $this->getInput('u');
|
|
$screenName = trim($screenName);
|
|
$screenName = ltrim($screenName, '@');
|
|
|
|
$data = $api->fetchUserTweets($screenName);
|
|
|
|
break;
|
|
|
|
case 'By keyword or hashtag':
|
|
// Does not work with the recent twitter changes
|
|
$params = [
|
|
'q' => urlencode($this->getInput('q')),
|
|
'tweet_mode' => 'extended',
|
|
'tweet_search_mode' => 'live',
|
|
];
|
|
|
|
$tweets = $api->search($params)->statuses;
|
|
$data = (object) [
|
|
'tweets' => $tweets
|
|
];
|
|
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')),
|
|
// 'tweet_mode' => 'extended',
|
|
// ];
|
|
$query = [
|
|
'screenName' => strtolower($this->getInput('user')),
|
|
'listSlug' => strtolower($this->getInput('list'))
|
|
];
|
|
|
|
$data = $api->fetchListTweets($query, $this->queriedContext);
|
|
break;
|
|
|
|
case 'By list ID':
|
|
// Does not work with the recent twitter changes
|
|
// $params = [
|
|
// 'list_id' => $this->getInput('listid'),
|
|
// 'tweet_mode' => 'extended',
|
|
// ];
|
|
|
|
$query = [
|
|
'listId' => $this->getInput('listid')
|
|
];
|
|
|
|
$data = $api->fetchListTweets($query, $this->queriedContext);
|
|
break;
|
|
default:
|
|
returnServerError('Invalid query context !');
|
|
}
|
|
|
|
if (!$data) {
|
|
switch ($this->queriedContext) {
|
|
case 'By keyword or hashtag':
|
|
returnServerError('twitter: No results for this query.');
|
|
// fall-through
|
|
case 'By username':
|
|
returnServerError('Requested username can\'t be found.');
|
|
// fall-through
|
|
case 'By list':
|
|
returnServerError('Requested username or list can\'t be found');
|
|
}
|
|
}
|
|
|
|
$hidePictures = $this->getInput('nopic');
|
|
|
|
$hidePinned = $this->getInput('nopinned');
|
|
if ($hidePinned) {
|
|
$pinnedTweetId = null;
|
|
if ($data->user_info && $data->user_info->legacy->pinned_tweet_ids_str) {
|
|
$pinnedTweetId = $data->user_info->legacy->pinned_tweet_ids_str[0];
|
|
}
|
|
}
|
|
|
|
// Array of Tweet IDs
|
|
$tweetIds = [];
|
|
// Filter out unwanted tweets
|
|
foreach ($data->tweets as $tweet) {
|
|
if (!$tweet) {
|
|
continue;
|
|
}
|
|
|
|
if (isset($tweet->legacy)) {
|
|
$legacy_info = $tweet->legacy;
|
|
} else {
|
|
$legacy_info = $tweet;
|
|
}
|
|
|
|
// Filter out retweets to remove possible duplicates of original tweet
|
|
switch ($this->queriedContext) {
|
|
case 'By keyword or hashtag':
|
|
// phpcs:ignore
|
|
if ((isset($legacy_info->retweeted_status) || isset($legacy_info->retweeted_status_result)) && substr($legacy_info->full_text, 0, 4) === 'RT @') {
|
|
continue 2;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Skip own Retweets...
|
|
if (isset($legacy_info->retweeted_status) && $legacy_info->retweeted_status->user->id_str === $tweet->user->id_str) {
|
|
continue;
|
|
// phpcs:ignore
|
|
} elseif (isset($legacy_info->retweeted_status_result) && $tweet->retweeted_status_result->result->legacy->user_id_str === $legacy_info->user_id_str) {
|
|
continue;
|
|
}
|
|
|
|
$tweetId = (isset($legacy_info->id_str) ? $legacy_info->id_str : $tweet->rest_id);
|
|
// Skip pinned tweet
|
|
if ($hidePinned && ($tweetId === $pinnedTweetId)) {
|
|
continue;
|
|
}
|
|
|
|
if (isset($tweet->rest_id)) {
|
|
$tweetIds[] = $tweetId;
|
|
}
|
|
$rtweet = $legacy_info;
|
|
$tweets[] = $rtweet;
|
|
}
|
|
|
|
if ($this->queriedContext === 'By username') {
|
|
$this->feedIconUrl = $data->user_info->legacy->profile_image_url_https ?? null;
|
|
}
|
|
|
|
$i = 0;
|
|
foreach ($tweets as $tweet) {
|
|
$item = [];
|
|
|
|
$realtweet = $tweet;
|
|
$tweetId = (isset($tweetIds[$i]) ? $tweetIds[$i] : $realtweet->conversation_id_str);
|
|
if (isset($tweet->retweeted_status)) {
|
|
// Tweet is a Retweet, so set author based on original tweet and set realtweet for reference to the right content
|
|
$realtweet = $tweet->retweeted_status;
|
|
} elseif (isset($tweet->retweeted_status_result)) {
|
|
$tweetId = $tweet->retweeted_status_result->result->rest_id;
|
|
$realtweet = $tweet->retweeted_status_result->result->legacy;
|
|
}
|
|
|
|
if (isset($realtweet->truncated) && $realtweet->truncated) {
|
|
try {
|
|
$realtweet = $this->getFullText($realtweet->id_str);
|
|
} catch (HttpException $e) {
|
|
$realtweet = $tweet;
|
|
}
|
|
}
|
|
|
|
if (!$realtweet) {
|
|
$realtweet = $tweet;
|
|
}
|
|
|
|
switch ($this->queriedContext) {
|
|
case 'By username':
|
|
if ($this->getInput('norep') && isset($tweet->in_reply_to_status_id)) {
|
|
continue 2;
|
|
}
|
|
$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['id'] = (isset($realtweet->id_str) ? $realtweet->id_str : $tweetId);
|
|
break;
|
|
case 'By list':
|
|
case 'By list ID':
|
|
$item['username'] = $data->userIds[$i]->legacy->screen_name;
|
|
$item['fullname'] = $data->userIds[$i]->legacy->name;
|
|
$item['avatar'] = $data->userIds[$i]->legacy->profile_image_url_https;
|
|
$item['id'] = $realtweet->conversation_id_str;
|
|
break;
|
|
case 'By keyword or hashtag':
|
|
$item['username'] = $realtweet->user->screen_name;
|
|
$item['fullname'] = $realtweet->user->name;
|
|
$item['avatar'] = $realtweet->user->profile_image_url_https;
|
|
$item['id'] = $realtweet->id_str;
|
|
break;
|
|
}
|
|
|
|
$item['timestamp'] = $realtweet->created_at;
|
|
$item['uri'] = self::URI . $item['username'] . '/status/' . $item['id'];
|
|
$item['author'] = ((isset($tweet->retweeted_status) || (isset($tweet->retweeted_status_result))) ? 'RT: ' : '')
|
|
. $item['fullname']
|
|
. ' (@'
|
|
. $item['username'] . ')';
|
|
|
|
// Convert plain text URLs into HTML hyperlinks
|
|
if (isset($realtweet->full_text)) {
|
|
$fulltext = $realtweet->full_text;
|
|
} else {
|
|
$fulltext = $realtweet->text;
|
|
}
|
|
$cleanedTweet = $fulltext;
|
|
|
|
$foundUrls = false;
|
|
|
|
if (substr($cleanedTweet, 0, 4) === 'RT @') {
|
|
$cleanedTweet = substr($cleanedTweet, 3);
|
|
}
|
|
|
|
if (isset($realtweet->entities->media)) {
|
|
foreach ($realtweet->entities->media as $media) {
|
|
$cleanedTweet = str_replace(
|
|
$media->url,
|
|
'<a href="' . $media->expanded_url . '">' . $media->display_url . '</a>',
|
|
$cleanedTweet
|
|
);
|
|
$foundUrls = true;
|
|
}
|
|
}
|
|
if (isset($realtweet->entities->urls)) {
|
|
foreach ($realtweet->entities->urls as $url) {
|
|
$cleanedTweet = str_replace(
|
|
$url->url,
|
|
'<a href="' . $url->expanded_url . '">' . $url->display_url . '</a>',
|
|
$cleanedTweet
|
|
);
|
|
$foundUrls = true;
|
|
}
|
|
}
|
|
if ($foundUrls === false) {
|
|
// fallback to regex'es
|
|
$reg_ex = '/(http|https|ftp|ftps)\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(\/\S*)?/';
|
|
if (preg_match($reg_ex, $fulltext, $url)) {
|
|
$cleanedTweet = preg_replace(
|
|
$reg_ex,
|
|
"<a href='{$url[0]}' target='_blank'>{$url[0]}</a> ",
|
|
$cleanedTweet
|
|
);
|
|
}
|
|
}
|
|
// generate the title
|
|
$item['title'] = strip_tags($cleanedTweet);
|
|
|
|
// Add avatar
|
|
$picture_html = '';
|
|
if (!$hidePictures) {
|
|
$picture_html = <<<EOD
|
|
<a href="https://twitter.com/{$item['username']}">
|
|
<img
|
|
style="align:top; width:75px; border:1px solid black;"
|
|
alt="{$item['username']}"
|
|
src="{$item['avatar']}"
|
|
title="{$item['fullname']}" />
|
|
</a>
|
|
EOD;
|
|
}
|
|
|
|
$medias = [];
|
|
if (isset($realtweet->extended_entities->media)) {
|
|
$medias = $realtweet->extended_entities->media;
|
|
} else if (isset($realtweet->mediaDetails)) {
|
|
$medias = $realtweet->mediaDetails;
|
|
}
|
|
|
|
// Get images
|
|
$media_html = '';
|
|
if (!$this->getInput('noimg')) {
|
|
foreach ($medias as $media) {
|
|
switch ($media->type) {
|
|
case 'photo':
|
|
$image = $media->media_url_https . '?name=orig';
|
|
$display_image = $media->media_url_https;
|
|
// add enclosures
|
|
$item['enclosures'][] = $image;
|
|
|
|
$media_html .= <<<EOD
|
|
<a href="{$image}">
|
|
<img
|
|
style="align:top; max-width:558px; border:1px solid black;"
|
|
referrerpolicy="no-referrer"
|
|
src="{$display_image}" />
|
|
</a>
|
|
EOD;
|
|
break;
|
|
case 'video':
|
|
case 'animated_gif':
|
|
if (isset($media->video_info)) {
|
|
$link = $media->expanded_url;
|
|
$poster = $media->media_url_https;
|
|
$video = null;
|
|
$maxBitrate = -1;
|
|
foreach ($media->video_info->variants as $variant) {
|
|
$bitRate = $variant->bitrate ?? -100;
|
|
if ($bitRate > $maxBitrate) {
|
|
$maxBitrate = $bitRate;
|
|
$video = $variant->url;
|
|
}
|
|
}
|
|
if (!is_null($video)) {
|
|
// add enclosures
|
|
$item['enclosures'][] = $video;
|
|
$item['enclosures'][] = $poster;
|
|
|
|
$media_html .= <<<EOD
|
|
<a href="{$link}">Video</a>
|
|
<video
|
|
style="align:top; max-width:558px; border:1px solid black;"
|
|
referrerpolicy="no-referrer"
|
|
src="{$video}" poster="{$poster}" />
|
|
EOD;
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
Debug::log('Missing support for media type: ' . $media->type);
|
|
}
|
|
}
|
|
}
|
|
|
|
switch ($this->queriedContext) {
|
|
case 'By list':
|
|
case 'By list ID':
|
|
// Check if filter applies to list (using raw content)
|
|
if ($this->getInput('filter')) {
|
|
if (stripos($cleanedTweet, $this->getInput('filter')) === false) {
|
|
continue 2; // switch + for-loop!
|
|
}
|
|
}
|
|
break;
|
|
case 'By username':
|
|
if ($this->getInput('noretweet') && strtolower($item['username']) != strtolower($this->getInput('u'))) {
|
|
continue 2; // switch + for-loop!
|
|
}
|
|
break;
|
|
default:
|
|
}
|
|
|
|
$item['content'] = <<<EOD
|
|
<div style="display: inline-block; vertical-align: top;">
|
|
{$picture_html}
|
|
</div>
|
|
<div style="display: inline-block; vertical-align: top;">
|
|
<blockquote>{$cleanedTweet}</blockquote>
|
|
</div>
|
|
<div style="display: block; vertical-align: top;">
|
|
<blockquote>{$media_html}</blockquote>
|
|
</div>
|
|
EOD;
|
|
|
|
// put out
|
|
$i++;
|
|
$this->items[] = $item;
|
|
}
|
|
|
|
usort($this->items, ['TwitterBridge', 'compareTweetId']);
|
|
}
|
|
|
|
public function getIcon()
|
|
{
|
|
return $this->feedIconUrl ?? parent::getIcon();
|
|
}
|
|
|
|
private static function compareTweetId($tweet1, $tweet2)
|
|
{
|
|
return (intval($tweet1['id']) < intval($tweet2['id']) ? 1 : -1);
|
|
}
|
|
}
|