Skip to content

Instantly share code, notes, and snippets.

@Gro-Tsen
Created December 7, 2024 20:54
Show Gist options
  • Save Gro-Tsen/3dcb18979a57172c63af1783bd0afdca to your computer and use it in GitHub Desktop.
Save Gro-Tsen/3dcb18979a57172c63af1783bd0afdca to your computer and use it in GitHub Desktop.
#! /usr/local/bin/perl -w
## A simple example of how to use the Bluesky protocol in Perl.
## Written by David A. Madore on 2024-12-07 -- Public Domain
## This version 2024-12-07
## This program attempts to download all your skeets (Bluesky posts),
## or all your likes on Bluesky, and prints them as a JSON file. This
## is a mere test of the Bluesky protocol or as an illustative example
## of how the protocol can be invoked in raw Perl: do not use this
## program for anything serious. It suffers from many limitations
## (e.g., if there are too many skeets to download, the authentication
## session will expire, and no attempt will be made to revalidate it).
## Also, the data is written as raw JSON as it was received from
## Bluesky, no attempt is made to do anything useful with it.
## Docs here:
## <URL: https://docs.bsky.app/docs/get-started >
## <URL: https://docs.bsky.app/docs/tutorials/viewing-feeds >
## <URL: https://docs.bsky.app/docs/api/com-atproto-server-create-session >
## <URL: https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed >
## Get an app password from <URL: https://bsky.app/settings/app-passwords >
## Pass as environment variables:
## BLUESKY_IDENTIFIER = your Bluesky handle, e.g. "example.bsky.social" (no '@')
## BLUESKY_PASSWORD = the app password obtained per previous link
## (note that this is NOT your account password, but like xxxx-xxxx-xxxx-xxxx)
## These parameters can also be passed as -u and -p options.
## Pass -l to get your likes instead of your skeets.
## Pass -a <target> to get someone else's skeets (where <target> is
## either their handle, or their did). You can't mix this with -l as
## likes aren't public.
## Pass -m <maxnum> to limit the number of feed requests that will be
## made (each supposed to return 100 posts), instead of trying to get
## to the end of the feed.
use strict;
use warnings;
use Getopt::Std;
use LWP::UserAgent;
use HTTP::Request;
use URI;
use JSON::XS;
use POSIX qw(strftime);
my %opts;
getopts("u:p:s:la:m:", \%opts);
my $do_likes = $opts{l};
my $target_actor = $opts{a};
my $max_req = $opts{m};
my $pds_server = $opts{s} // "bsky.social";
# my $json_coder_unicode = JSON::XS->new;
# my $json_decoder_unicode = JSON::XS->new;
my $json_coder_utf8 = JSON::XS->new->utf8;
my $json_decoder_utf8 = JSON::XS->new->utf8;
my $bluesky_identifier = $opts{u} // $ENV{"BLUESKY_IDENTIFIER"};
die "need BLUESKY_IDENTIFIER environment variable" unless defined($bluesky_identifier);
$bluesky_identifier =~ s/^\@//;
my $bluesky_password = $opts{p} // $ENV{"BLUESKY_PASSWORD"};
die "need BLUESKY_PASSWORD environment variable" unless defined($bluesky_password);
my $lwp = LWP::UserAgent->new;
## Authentication
my $getSession_uri = "https://$pds_server/xrpc/com.atproto.server.createSession";
my $identification_json = $json_coder_utf8->encode({
"identifier" => $bluesky_identifier,
"password" => $bluesky_password,
});
my $getSession_req = HTTP::Request->new("POST", $getSession_uri);
$getSession_req->header("Content-Type" => "application/json");
$getSession_req->content($identification_json);
my $getSession_resp = $lwp->request($getSession_req);
die $getSession_resp->status_line unless $getSession_resp->is_success;
my $session_json = $json_coder_utf8->decode($getSession_resp->decoded_content);
my $user_did = $session_json->{"did"};
die "missing did" unless defined($user_did);
my $access_jwt = $session_json->{"accessJwt"};
die "missing accessJwt" unless defined($access_jwt);
print STDERR "authenticated successfully (did=${user_did})\n";
## Get targeted actor's profile:
if ( defined($target_actor) ) {
unless ( $target_actor =~ m/^did\:/ ) {
$target_actor =~ s/^\@//;
my $getProfile_base_uri = "https://$pds_server/xrpc/app.bsky.actor.getProfile";
my $getProfile_uri = URI->new($getProfile_base_uri);
$getProfile_uri->query_form("actor" => $target_actor);
# printf STDERR "request URI is %s\n", $getProfile_uri;
my $getProfile_req = HTTP::Request->new("GET", $getProfile_uri);
$getProfile_req->header("Authorization" => "Bearer $access_jwt");
my $getProfile_resp = $lwp->request($getProfile_req);
die $getProfile_resp->status_line unless $getProfile_resp->is_success;
my $profile_json = $json_coder_utf8->decode($getProfile_resp->decoded_content);
die "missing did" unless defined($profile_json->{"did"});
$target_actor = $profile_json->{"did"};
printf STDERR "found targeted actor (did=%s, displayName=\"%s\")\n", $profile_json->{"did"}, ($profile_json->{"displayName"}//"(undef)");
}
} else {
$target_actor = $user_did;
}
print STDERR "will target actor: $target_actor\n";
## Get author feed or likes:
my $fullfeed = [];
my $cursor;
my $getFeed_base_uri = $do_likes ? "https://$pds_server/xrpc/app.bsky.feed.getActorLikes" : "https://$pds_server/xrpc/app.bsky.feed.getAuthorFeed";
my $cnt = 0;
while ( 1 ) {
last if defined($max_req) && $cnt >= $max_req;
printf STDERR "request %d: getting feed with cursor=%s\n", $cnt, $cursor//"(undef)";
my $getFeed_uri = URI->new($getFeed_base_uri);
$getFeed_uri->query_form("actor" => $target_actor,
"limit" => 100);
$getFeed_uri->query_param("filter" => "posts_with_replies") unless $do_likes;
$getFeed_uri->query_param("cursor" => $cursor) if defined($cursor);
# printf STDERR "request URI is %s\n", $getFeed_uri;
my $getFeed_req = HTTP::Request->new("GET", $getFeed_uri);
$getFeed_req->header("Authorization" => "Bearer $access_jwt");
my $getFeed_resp = $lwp->request($getFeed_req);
unless ( $getFeed_resp->is_success ) {
warn $getFeed_resp->status_line;
last;
}
my $feed_json = $json_coder_utf8->decode($getFeed_resp->decoded_content);
last unless defined($feed_json->{"feed"});
last unless scalar(@{$feed_json->{"feed"}});
printf STDERR "... got %d posts (new cursor: %s)\n", scalar(@{$feed_json->{"feed"}}), ($feed_json->{"cursor"}//"(undef)");
push @{$fullfeed}, @{$feed_json->{"feed"}};
last unless defined($feed_json->{"cursor"});
$cursor = $feed_json->{"cursor"};
$cnt++;
sleep 0.5;
}
print $json_coder_utf8->encode({ "feed" => $fullfeed });
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment