Created
December 7, 2024 20:54
-
-
Save Gro-Tsen/3dcb18979a57172c63af1783bd0afdca to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#! /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