RSS-Bridge / rss-bridge

The RSS feed for websites missing it
https://rss-bridge.org/bridge01/
The Unlicense
7.37k stars 1.04k forks source link

Bridge request for Bluesky #4058

Open ontheair81 opened 7 months ago

ontheair81 commented 7 months ago

Bridge request

General information

Options

Additional notes

This bridge request was created after this discussion.

bluesky

other_feeds

dvikan commented 7 months ago

I tried making a bridge for bluesky some months ago but gave up due to their annoying API.

Aasemoon commented 5 months ago

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.

dvikan commented 5 months ago

https://openrss.org/blog/bluesky-has-launched-rss-feeds

mruac commented 2 months ago

This is currently available in Bluestream

thomas-333 commented 1 week ago

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.

thomas-333 commented 1 week ago

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';
    }
}
eMerzh commented 1 week ago

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 🎉

eMerzh commented 6 days ago

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

thomas-333 commented 5 days ago

@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?]

thomas-333 commented 4 days ago

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';
    }
}
dvikan commented 2 days ago

please create a PR for bluesky! lots of users want this

eMerzh commented 1 day ago

@thomas-333 you create that? we can always amend that later :)

thomas-333 commented 1 day ago

PR created.