Open ontheair81 opened 7 months ago
I tried making a bridge for bluesky some months ago but gave up due to their annoying API.
I just would like to +1 this. I'd also love a feed for Bluesky that actually shows images and links. I'd also like to be able to see reposts, that don't even show up in the available feed at the moment.
This is currently available in Bluestream
Found this through Google when searching if there was a bridge. In case this helps anyone else I've ended up using RSSHub to generate the feed, including links, images, replies etc, as I like to self host rather than use say openrss.org.
In case this helps anyone else I have converted the rsshub code. All credit goes to the original author.
This shows all posts that appear in user's feeds, unlike the official rss feeds and includes images and links. Ideally I would like for quote posts for the quoted post to also be included. However I'm not a coder and this is beyond my ability.
<?php
class BlueskyProfileBridge extends BridgeAbstract {
const NAME = 'Bluesky Profile Posts';
const URI = 'https://bsky.app';
const DESCRIPTION = 'Fetches posts from a Bluesky profile';
const MAINTAINER = 'Code modified from rsshub (TonyRL https://github.com/TonyRL) and expanded';
const PARAMETERS = [
[
'handle' => [
'name' => 'User Handle',
'type' => 'text',
'required' => true,
'exampleValue' => 'bsky.app',
'title' => 'Handle found in URL'
],
'filter' => [
'name' => 'Filter',
'type' => 'text',
'defaultValue' => 'posts_and_author_threads',
'exampleValue' => 'posts_and_author_threads',
'title' => 'Content filter: posts_and_author_threads, posts_with_replies, posts_no_replies, posts_with_media'
]
]
];
private $profile;
public function getName() {
if (isset($this->profile)) {
return sprintf("%s (@%s) - Bluesky", $this->profile['displayName'], $this->profile['handle']);
}
return parent::getName();
}
public function getURI() {
if (isset($this->profile)) {
return self::URI . '/profile/' . $this->profile['handle'];
}
return parent::getURI();
}
public function collectData() {
$handle = $this->getInput('handle');
$filter = $this->getInput('filter') ?: 'posts_and_author_threads';
$did = $this->resolveHandle($handle);
$this->profile = $this->getProfile($did);
$authorFeed = $this->getAuthorFeed($did, $filter);
foreach ($authorFeed['feed'] as $post) {
$item = [];
$item['uri'] = self::URI . '/profile/' . $post['post']['author']['handle'] . '/post/' . explode('app.bsky.feed.post/', $post['post']['uri'])[1];
$item['title'] = strtok($post['post']['record']['text'], "\n");
$item['timestamp'] = strtotime($post['post']['record']['createdAt']);
$item['author'] = $this->profile['displayName'];
// Convert text to HTML with line breaks
$text = nl2br(htmlspecialchars($post['post']['record']['text'], ENT_QUOTES, 'UTF-8'));
// Convert URLs in text to clickable links
$text = preg_replace('/(https?:\/\/[^\s]+)/i', '<a href="$1">$1</a>', $text);
$description = $text;
// Handle external link embeds
if ($post['post']['record']['embed']['$type'] === 'app.bsky.embed.external') {
$external = $post['post']['record']['embed']['external'];
$externalUri = $external['uri'];
$externalTitle = htmlspecialchars($external['title'], ENT_QUOTES, 'UTF-8');
$externalDescription = htmlspecialchars($external['description'], ENT_QUOTES, 'UTF-8');
$thumb = $external['thumb'] ?? null;
// Check if the link is a YouTube link
if (preg_match('/youtube\.com\/watch\?v=([^\&\?\/]+)/', $externalUri, $id) || preg_match('/youtu\.be\/([^\&\?\/]+)/', $externalUri, $id)) {
// YouTube video identified, generate iframe
$videoId = $id[1];
$description .= "<p>External Link: <a href=\"$externalUri\">$externalTitle</a></p>";
$description .= "<iframe width=\"560\" height=\"315\" src=\"https://www.youtube.com/embed/$videoId\" frameborder=\"0\" allowfullscreen></iframe>";
} else {
$description .= "<p>External Link: <a href=\"$externalUri\">$externalTitle</a></p>";
$description .= "<p>$externalDescription</p>";
if ($thumb) {
$thumbUrl = 'https://cdn.bsky.app/img/feed_thumbnail/plain/' . $post['post']['author']['did'] . '/' . $thumb['ref']['$link'] . '@jpeg';
$description .= "<p><a href=\"$externalUri\"><img src=\"$thumbUrl\" alt=\"External Thumbnail\" /></a></p>";
}
}
}
// Handle video embeds with thumbnails
if ($post['post']['record']['embed']['$type'] === 'app.bsky.embed.video') {
$thumbnail = $post['post']['embed']['thumbnail'] ?? null;
if ($thumbnail) {
$description .= "<p><img src=\"$thumbnail\" alt=\"Video Thumbnail\" /></p>";
}
}
// Retrieve DID for constructing image URLs
$authorDid = $post['post']['author']['did'];
// Handle image embeds
if (!empty($post['post']['record']['embed']['images'])) {
foreach ($post['post']['record']['embed']['images'] as $image) {
$linkRef = $image['image']['ref']['$link'];
$thumbnailUrl = $this->resolveThumbnailUrl($authorDid, $linkRef);
$fullsizeUrl = $this->resolveFullsizeUrl($authorDid, $linkRef);
$description .= "<br /><br /><a href=\"$fullsizeUrl\"><img src=\"$thumbnailUrl\" alt=\"Image\"></a>";
}
}
$item['content'] = $description;
$this->items[] = $item;
}
}
private function resolveHandle($handle) {
$uri = 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=' . urlencode($handle);
$response = json_decode(file_get_contents($uri), true);
return $response['did'];
}
private function getProfile($did) {
$uri = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=' . urlencode($did);
$response = json_decode(file_get_contents($uri), true);
return $response;
}
private function getAuthorFeed($did, $filter) {
$uri = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=' . urlencode($did) . '&filter=' . urlencode($filter) . '&limit=30';
$response = json_decode(file_get_contents($uri), true);
return $response;
}
private function resolveThumbnailUrl($authorDid, $linkRef) {
return 'https://cdn.bsky.app/img/feed_thumbnail/plain/' . $authorDid . '/' . $linkRef . '@jpeg';
}
private function resolveFullsizeUrl($authorDid, $linkRef) {
return 'https://cdn.bsky.app/img/feed_fullsize/plain/' . $authorDid . '/' . $linkRef . '@jpeg';
}
}
i just stumble on this by chance, i'm also interested in a bridge with more full rss than what's currently provided....
thanks @thomas-333 for the share,
it seems i had to replace file_get_contents with getContents, but after that it worked 🎉
i've made the changes here https://gist.github.com/eMerzh/d8b36a0147d6896b082d34374c4c4067 i'll continue playing a bit, and maybe submit a PR if it goes somewhere
@eMerzh Glad it's working for you. Not sure about the file_get_contents issue as it works that work for me. Perhaps your configuration diffs to mine.
[Edit: Your version with getContents works fine for me as well. I see you have also made a few other tweaks. Should quote replies be working?]
Just sharing my latest version based on @eMerzh version. This includes certain types of quoted posts and certain types of images in quoted posts. This is certainty better than my original but still a WIP and at the limit of what I can personally manage. Just sharing as this is still much better than the official Bluesky rss feeds.
<?php
class BlueskyProfileBridge extends BridgeAbstract {
const NAME = 'Bluesky Profile Posts';
const URI = 'https://bsky.app';
const DESCRIPTION = 'Fetches posts from a Bluesky profile';
const MAINTAINER = 'Code modified from rsshub (TonyRL https://github.com/TonyRL) and expanded';
const PARAMETERS = [
[
'handle' => [
'name' => 'User Handle',
'type' => 'text',
'required' => true,
'exampleValue' => 'jackdodo.bsky.social',
'title' => 'Handle found in URL'
],
'filter' => [
'name' => 'Filter',
'type' => 'list',
'defaultValue' => 'posts_and_author_threads',
'values' => [
'posts_and_author_threads' => 'posts_and_author_threads',
'posts_with_replies' => 'posts_with_replies',
'posts_no_replies' => 'posts_no_replies',
'posts_with_media' => 'posts_with_media',
],
'title' => 'Combinations of post/repost types to include in response.'
]
]
];
private $profile;
public function getName() {
if (isset($this->profile)) {
return sprintf("%s (@%s) - Bluesky", $this->profile['displayName'], $this->profile['handle']);
}
return parent::getName();
}
public function getURI() {
if (isset($this->profile)) {
return self::URI . '/profile/' . $this->profile['handle'];
}
return parent::getURI();
}
public function getIcon() {
if (isset($this->profile)) {
return $this->profile['avatar'];
}
return parent::getIcon();
}
public function getDescription() {
if (isset($this->profile)) {
return $this->profile['description'];
}
return parent::getDescription();
}
private function parseExternal($external, $did) {
$description = '';
$externalUri = $external['uri'];
$externalTitle = htmlspecialchars($external['title'], ENT_QUOTES, 'UTF-8');
$externalDescription = htmlspecialchars($external['description'], ENT_QUOTES, 'UTF-8');
$thumb = $external['thumb'] ?? null;
if (preg_match('/youtube\.com\/watch\?v=([^\&\?\/]+)/', $externalUri, $id) || preg_match('/youtu\.be\/([^\&\?\/]+)/', $externalUri, $id)) {
$videoId = $id[1];
$description .= "<p>External Link: <a href=\"$externalUri\">$externalTitle</a></p>";
$description .= "<iframe width=\"560\" height=\"315\" src=\"https://www.youtube.com/embed/$videoId\" frameborder=\"0\" allowfullscreen></iframe>";
} else {
$description .= "<p>External Link: <a href=\"$externalUri\">$externalTitle</a></p>";
$description .= "<p>$externalDescription</p>";
if ($thumb) {
$thumbUrl = 'https://cdn.bsky.app/img/feed_thumbnail/plain/' . $did . '/' . $thumb['ref']['$link'] . '@jpeg';
$description .= "<p><a href=\"$externalUri\"><img src=\"$thumbUrl\" alt=\"External Thumbnail\" /></a></p>";
}
}
return $description;
}
private function textToDescription($text) {
$text = nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'));
$text = preg_replace('/(https?:\/\/[^\s]+)/i', '<a href="$1">$1</a>', $text);
return $text;
}
public function collectData() {
$handle = $this->getInput('handle');
$filter = $this->getInput('filter') ?: 'posts_and_author_threads';
$did = $this->resolveHandle($handle);
$this->profile = $this->getProfile($did);
$authorFeed = $this->getAuthorFeed($did, $filter);
foreach ($authorFeed['feed'] as $post) {
$item = [];
$item['uri'] = self::URI . '/profile/' . $post['post']['author']['handle'] . '/post/' . explode('app.bsky.feed.post/', $post['post']['uri'])[1];
$item['title'] = strtok($post['post']['record']['text'], "\n");
$item['timestamp'] = strtotime($post['post']['record']['createdAt']);
$item['author'] = $this->profile['displayName'];
$description = $this->textToDescription($post['post']['record']['text']);
// Retrieve DID for constructing image URLs
$authorDid = $post['post']['author']['did'];
if (isset($post['post']['record']['embed']['$type']) && $post['post']['record']['embed']['$type'] === 'app.bsky.embed.external') {
$description .= $this->parseExternal($post['post']['record']['embed']['external'], $authorDid);
}
if (isset($post['post']['record']['embed']['$type']) && $post['post']['record']['embed']['$type'] === 'app.bsky.embed.video') {
$thumbnail = $post['post']['embed']['thumbnail'] ?? null;
if ($thumbnail) {
$itemUri = self::URI . '/profile/' . $post['post']['author']['handle'] . '/post/' . explode('app.bsky.feed.post/', $post['post']['uri'])[1];
$description .= "<p><a href=\"$itemUri\"><img src=\"$thumbnail\" alt=\"Video Thumbnail\" /></a></p>";
}
}
if (isset($post['post']['record']['embed']['$type']) && $post['post']['record']['embed']['$type'] === 'app.bsky.embed.recordWithMedia#view') {
$thumbnail = $post['post']['embed']['media']['thumbnail'] ?? null;
$playlist = $post['post']['embed']['media']['playlist'] ?? null;
if ($thumbnail) {
$description .= "<p><video controls poster=\"$thumbnail\">";
$description .= "<source src=\"$playlist\" type=\"application/x-mpegURL\">";
$description .= "Video source not supported</video></p>";
}
}
if (!empty($post['post']['record']['embed']['images'])) {
foreach ($post['post']['record']['embed']['images'] as $image) {
$linkRef = $image['image']['ref']['$link'];
$thumbnailUrl = $this->resolveThumbnailUrl($authorDid, $linkRef);
$fullsizeUrl = $this->resolveFullsizeUrl($authorDid, $linkRef);
$description .= "<br /><br /><a href=\"$fullsizeUrl\"><img src=\"$thumbnailUrl\" alt=\"Image\"></a>";
}
}
// Enhanced handling for quote posts with images
if (isset($post['post']['record']['embed']) && $post['post']['record']['embed']['$type'] === 'app.bsky.embed.record') {
$quotedRecord = $post['post']['record']['embed']['record'];
$quotedAuthor = $post['post']['embed']['record']['author']['handle'] ?? null;
$quotedDisplayName = $post['post']['embed']['record']['author']['displayName'] ?? null;
$quotedText = $post['post']['embed']['record']['value']['text'] ?? null;
if ($quotedAuthor && isset($quotedRecord['uri'])) {
$quotedPostId = end(explode('/', $quotedRecord['uri']));
$quotedPostUri = self::URI . '/profile/' . $quotedAuthor . '/post/' . $quotedPostId;
}
if ($quotedText) {
$description .= "<hr /><strong>Quote from " . htmlspecialchars($quotedDisplayName) . " (@ " . htmlspecialchars($quotedAuthor) . "):</strong><br />";
$description .= $this->textToDescription($quotedText);
if (isset($quotedPostUri)) {
$description .= "<p><a href=\"$quotedPostUri\">View original quote post</a></p>";
}
}
}
if (isset($post['post']['embed']['record']['value']['embed']['images'])) {
$quotedImages = $post['post']['embed']['record']['value']['embed']['images'];
foreach ($quotedImages as $image) {
$linkRef = $image['image']['ref']['$link'] ?? null;
if ($linkRef) {
$quotedAuthorDid = $post['post']['embed']['record']['author']['did'] ?? null;
$thumbnailUrl = $this->resolveThumbnailUrl($quotedAuthorDid, $linkRef);
$fullsizeUrl = $this->resolveFullsizeUrl($quotedAuthorDid, $linkRef);
$description .= "<br /><br /><a href=\"$fullsizeUrl\"><img src=\"$thumbnailUrl\" alt=\"Quoted Image\"></a>";
}
}
}
$item['content'] = $description;
$this->items[] = $item;
}
}
private function resolveHandle($handle) {
$uri = 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=' . urlencode($handle);
$response = json_decode(getContents($uri), true);
return $response['did'];
}
private function getProfile($did) {
$uri = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=' . urlencode($did);
$response = json_decode(getContents($uri), true);
return $response;
}
private function getAuthorFeed($did, $filter) {
$uri = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=' . urlencode($did) . '&filter=' . urlencode($filter) . '&limit=30';
$response = json_decode(getContents($uri), true);
return $response;
}
private function resolveThumbnailUrl($authorDid, $linkRef) {
return 'https://cdn.bsky.app/img/feed_thumbnail/plain/' . $authorDid . '/' . $linkRef . '@jpeg';
}
private function resolveFullsizeUrl($authorDid, $linkRef) {
return 'https://cdn.bsky.app/img/feed_fullsize/plain/' . $authorDid . '/' . $linkRef . '@jpeg';
}
}
please create a PR for bluesky! lots of users want this
@thomas-333 you create that? we can always amend that later :)
PR created.
Bridge request
General information
Host URI for the bridge (i.e.
https://github.com
): https://bsky.socialWhich information would you like to see? Item titles, short text content and images.
How should the information be displayed/formatted? It should be formatted in in a way, that the title and the corresponding picture is shown in the feed preview (without opening the article to see the full content). Please see the attached screenshots below. Bluesky offers public RSS feeds out of the box, but they are formatted in a way which makes them almost useless, in my opinion (no item titles and no images). One screenshot shows the current RSS implementation of Bluesky, the other one is an example how all other feeds (I am aware of) are displayed. The screenshots are taken from the Android app of a self-hosted instance of TT-RSS.
Which of the following parameters do you expect?
Options
Additional notes
This bridge request was created after this discussion.