#!/opt/local/bin/perl -w # Copyright © 2012-2023 Jamie Zawinski # # Permission to use, copy, modify, distribute, and sell this software and its # documentation for any purpose is hereby granted without fee, provided that # the above copyright notice appear in all copies and that both that # copyright notice and this permission notice appear in supporting # documentation. No representations are made about the suitability of this # software for any purpose. It is provided "as is" without express or # implied warranty. # # Post an image or video to Facebook. # # Requires resize.pl (https://www.jwz.org/hacks/#resize) and ffmpeg to # adjust the image or video to the proper size and dimensions. # # Installation: # # Expects $HOME/.USER-facebook-pass to exist and contain secrets. # Create it by running this with --generate-session # # To use --instagram, edit $instagram_staging_dir and $instagram_staging_url # to point to your local web root and public URL. # # Usage: # # facebook-upload.pl USER --caption "TEXT" IMAGE-OR-VIDEO-FILE # # --stats prints title, follower-count, checkin-count # --delete FROM TO attempts to delete any posts between the 2 dates. # # Pared down descendant of fbmirror.pl and facebook-rss.pl. # Created: 21-Nov-2022. # 9-Mar-2023, Here's how I created a new app: # # create new app: business # app name "DNA Lounge Page Image Uploader" # booking@ # business account dna lounge # # add products # instagram graph api # facebook login for business # [x] client oauth login # [x] web oauth login # [x] enforce https # [ ] force web oauth reauth # [ ] embedded browser # [x] use strict mode # valid oauth redirect uris # https://www.dnalounge.com/ .../oauth.cgi # [ ] login from devices # [ ] login from JS # then click "switch to facebook login" # # basic # app domains dnalounge.com # namespace - blank # privacy policy url ..../policy.html # terms of service url: same # category business and pages # app icon # # advanced # [ ] native or desktop # api version 10.0 -> 16.0 # [ ] references alcohol # [ ] social discovery # age restriction anyone # [ ] gdpr restricted # [ ] country restricted # [ ] require app secret (was checked) # [ ] allow api access to aap settings (was checked) # [ ] 2fa # app page: blank # # roles: unable to add dan # top of dashboard, click app mode checkbox to Live require 5; use diagnostics; use strict; # For resize.pl and ffmpeg. $ENV{PATH} = "/opt/local/bin:/var/www/jwz/hacks:$ENV{PATH}"; use POSIX; use Digest::SHA; use HTML::Entities; use LWP::Simple; use LWP::UserAgent; use HTML::Entities; use JSON::Any; use Date::Parse; use Data::Dumper; use Time::ParseDate; my $progname = $0; $progname =~ s@.*/@@g; my ($version) = ('$Revision: 1.11 $' =~ m/\s(\d[.\d]+)\s/s); my $verbose = 0; my $debug_p = 0; # FFS my $instagram_staging_dir = "/var/www/dnalounge/facebook"; my $instagram_staging_url = "https://www.dnalounge.com/facebook"; # Anything placed on this list gets unconditionally deleted when this # script exits, even if abnormally. # my %rm_f; END { rmf(); } sub rmf() { foreach my $f (sort keys %rm_f) { if (-e $f) { print STDERR blurb() . "rm $f\n" if ($verbose > 1); unlink $f; } } %rm_f = (); } sub rm_atexit($) { my ($file) = @_; $rm_f{$file} = 1; } sub signal_cleanup($) { my ($s) = @_; print STDERR blurb() . "SIG$s\n" if ($verbose > 1); rmf(); # Propagate the signal and die. This does not cause END to run. $SIG{$s} = 'DEFAULT'; kill ($s, $$); } $SIG{TERM} = \&signal_cleanup; # kill $SIG{INT} = \&signal_cleanup; # shell ^C $SIG{QUIT} = \&signal_cleanup; # shell ^| $SIG{KILL} = \&signal_cleanup; # nope $SIG{ABRT} = \&signal_cleanup; $SIG{HUP} = \&signal_cleanup; $SIG{PIPE} = 'IGNORE'; my @sites = (["DNA Lounge", "https://www.dnalounge.com/", "/var/www/dnalounge"], ["DNA Pizza", "https://www.dnapizza.com/", "/var/www/dnapizza"], ["Codeword", "https://www.codeword-sf.com/", "/var/www/codeword-sf"], ["jwz", "https://www.jwz.org/blog/", "/var/www/jwz"]); my $facebook_upload_videos_p = 1; my %page_ids = ( dnalounge => 12161711085, dnalounge2 => 12161711085, dnapizza => 192927147394687 ); sub blurb() { return $progname . POSIX::strftime(': %l:%M:%S %p: ', localtime); } sub site_info($) { my ($user) = @_; my $n = ($user =~ m/dnalounge/s ? 0 : $user =~ m/dnapizza/s ? 1 : $user =~ m/codeword/s ? 2 : $user =~ m/jwz/s ? 3 : 0); return @{$sites[$n]}; } # Convert any HTML entities to Unicode characters. # sub html_unquote($) { my ($s) = @_; return undef unless defined($s); return $s unless ($s =~ m/&/s); # Faster? return HTML::Entities::decode_entities ($s); } sub url_quote($) { my ($u) = @_; $u =~ s|([^-a-zA-Z0-9.\@_\r\n])|sprintf("%%%02X", ord($1))|ge; return $u; } sub url_unquote($) { my ($url) = @_; $url =~ s/[+]/ /g; $url =~ s/%([a-z0-9]{2})/chr(hex($1))/ige; return $url; } sub ext_to_ct($) { my ($file) = @_; return undef unless defined($file); $file =~ s@^.*/@@s; $file =~ s@^.*\.@@s; return ($file =~ m/^p?jpe?g$/si ? 'image/jpeg' : $file =~ m/^gif$/si ? 'image/gif' : $file =~ m/^png$/si ? 'image/png' : $file =~ m/^mp4$/si ? 'video/mp4' : $file =~ m/^m4v$/si ? 'video/mp4' : $file =~ m/^mov$/si ? 'video/quicktime' : $file =~ m/^ts$/si ? 'video/mp2t' : $file =~ m/^m3u8?$/si ? 'application/x-mpegurl' : $file =~ m/^([^.\/]+)$/si ? "image/$1" : 'application/octet-stream'); } sub ct_to_ext($) { my ($ct) = @_; return undef unless defined($ct); $ct = lc($ct); $ct =~ s/[;\s].*$//s; return ($ct =~ m@^image/jpeg@si ? 'jpg' : $ct =~ m@^video/m4v@si ? 'mp4' : $ct =~ m@^video/quicktime@si ? 'mov' : $ct =~ m@^video/mp2t@si ? 'ts' : $ct =~ m@^(image|video)/([^/\s]+)@si ? lc($2) : $ct =~ m@^(application|video)/(x-)?mpegurl@si ? 'm3u8' : 'unknown'); } # Like system() but respects error codes. # sub safe_system(@) { my @cmd = @_; print STDERR "$progname: exec: " . join(' ', @cmd) . "\n" if ($verbose > 1); system (@cmd); my $exit_value = $? >> 8; my $signal_num = $? & 127; my $dumped_core = $? & 128; error ("$cmd[0]: core dumped!") if ($dumped_core); error ("$cmd[0]: signal $signal_num!") if ($signal_num); error ("$cmd[0]: exited with $exit_value!") if ($exit_value); return $exit_value; } sub generate_session($) { my ($app) = @_; my @perms = (#'read_stream', #'read_mailbox', # deprecated #'user_posts', #'user_events', # The commented-out ones are no longer valid: #'rsvp_event', #'user_likes', #'friends_likes', #'user_status', #'friends_status', #'user_checkins', #'friends_checkins', #'user_photos', #'friends_photos', #'user_birthday', #'friends_birthday', #'user_location', #'friends_location', #'user_photo_video_tags', #'friends_photo_video_tags', #'user_videos', #'friends_videos', #'publish_stream', #'user_groups', #'user_managed_groups', # Removed 22-Feb-2023 #'manage_pages', #'publish_pages', #'publish_actions', 'ads_management', 'ads_read', # Removed Feb 2021 #'read_audience_network_insights', #'read_insights', #'pages_manage_cta', #'pages_manage_instant_articles', 'pages_show_list', #'read_page_mailboxes', # Added Sep 2020, removed Feb 2021. Needed again, June 2021? 'pages_manage_posts', 'publish_to_groups', # This too? # Added 22-Feb-2023 'instagram_basic', 'instagram_content_publish', 'instagram_manage_insights', # Not needed? ); print STDOUT (' 1) Go to https://developers.facebook.com/apps and create an "App". You only need to do this once. 2) Click on the name of the app that you created. Enter its "App ID" here: '); my $app_id = <>; chomp ($app_id); error ("That's not an App ID: \"$app_id\"") unless ($app_id =~ m/^\d{8,}$/s); my $data = fb_load ("app id", undef, $app_id); my $name = $data ? $data->{namespace} || $data->{name} : 0; error ("Unknown app ID: $app_id") unless $name; $name = lc($name); $name =~ s/\s+//s; print STDOUT ("3) Enter its \"App Secret\" here: "); my $app_secret = <>; chomp ($app_secret); error ("That's not an app secret: \"$app_secret\"") unless ($app_secret =~ m/^[\da-f]{24,}$/si); print STDOUT ("4) Select the \"Facebook Login / Settings\" tab and " . "enter the URL in the\n" . "\"Valid OAuth Redirect URIs field\"," . " a URL on your web site: "); my $site = <>; chomp ($site); error ("That's not a URL: $site") unless ($site =~ m@^https?://@s); error ("That's not an app secret: \"$app_secret\"") unless ($app_secret =~ m/^[\da-f]{24,}$/si); my $url = ('https://graph.facebook.com/oauth/authorize' . '?type=user_agent' . '&client_id=' . $app_id . '&redirect_uri=' . url_quote($site) . '&scope=' . join(',', @perms)); print STDOUT "\n"; print STDOUT "5) Go to this URL in a browser that is logged in to Facebook:". "\n\n $url\n\n"; print STDOUT "6) Say yes if there are any questions.\n"; print STDOUT "7) You should be redirected to a page saying 'Success'.\n"; print STDOUT " or just '0'.\n"; print STDOUT " If it complains about invalid scopes, remove them\n"; print STDOUT " and try again.\n"; print STDOUT " Enter the URL of that page here: "; $url = <>; chomp ($url); my ($token) = ($url =~ m@access_token=([^?&<>]+)@si); error ("There's no \"access_token=\" in that URL!\n" . "\t\t Maybe try it again with JavaScript disabled?") unless $token; my $fn = $ENV{HOME} . "/.$name-facebook-pass"; my $body = ''; if (open (my $in, '<', $fn)) { local $/ = undef; # read entire file $body = <$in>; close $in; } if (! ($body =~ s/^((ACCESS_TOKEN|OAUTH2):[ \t]*)([^\n]*)/$1$token/mi)) { $body .= "OAUTH2:\t$token\n"; } if (! ($body =~ s/^((SECRET):[ \t]*)([^\n]*)/$1$app_secret/mi)) { $body .= "SECRET:\t$app_secret\n"; } open (my $out, '>', $fn) || error ("$fn: $!"); print $out $body; close $out; system ("chmod", "og-rw", $fn); print STDOUT "\nDone! $fn has been updated with your\n" . "new access token. Keep it secret.\n\n"; } sub load_keys($;$) { my ($user, $noerror) = @_; my $file = $ENV{HOME} . "/.$user-facebook-pass"; my $token = undef; my $secret = undef; open (my $in, '<', $file) || error ("$file: $!"); while (<$in>) { if (m/^(?:OAUTH2|ACCESS_TOKEN):\s*(.*?)\s*$/s) { $token = $1; } elsif (m/^(?:SECRET):\s*(.*?)\s*$/s) { $secret = $1; } } close $in; error ("no access token in $file\n\n" . "\t\t run: $progname --generate-session\n") unless $token; print STDERR blurb() . "read $file\n" if ($verbose > 2); my $proof = ($secret ? Digest::SHA::hmac_sha256_hex ($token, $secret) : undef); return [ $user, $token, undef, $secret, $proof ]; } # Load a URL from Facebook, convert JSON to hashrefs. # Retry a few times if it fails. # Duplicated in hacks/facebook-rss.pl # Duplicated in facebook/cover-sync.pl # my $fb_first_time_p = 1; sub fb_load($$$;$$) { my ($description, $auth, $args, $post_args, $ignore_errors) = @_; $ignore_errors = 0 unless defined ($ignore_errors); my $url; if ($args =~ m/^http/s) { $url = $args; } else { $url = 'https://graph.facebook.com/'; $args =~ s/\?/&/g; $args =~ s/&/?/; $url .= $args; } if ($post_args) { foreach my $k (keys %$post_args) { # Leave image data alone next if ($k eq 'source' || $k eq 'video_file_chunk'); my $v = $post_args->{$k}; utf8::encode ($v); # Unpack wide chars to multi-byte UTF-8. $post_args->{$k} = $v; } } # If we have not yet swapped to a page token, do it now, lazily. fb_swap_token ($auth); my ($user, $token, $page_token, undef, $proof) = ($auth ? @$auth : ()); if ($post_args) { $post_args->{access_token} = $page_token; $post_args->{appsecret_proof} = $proof; } elsif ($page_token) { $url .= (($url =~ m/[?]/ ? '&' : '?') . 'access_token=' . $page_token . '&appsecret_proof=' . $proof); } my $obj = undef; my $err = undef; my $start = time(); my $delay = 2; my $retries = ($debug_p ? 1 : 10); $retries = 1 if ($ignore_errors > 1); my $ua = $LWP::Simple::ua; $ua->agent("$progname/$version"); $ua->timeout ($post_args ? 30 : 10); my $i; for ($i = 0; $i < $retries; $i++) { my $res = undef; $err = undef; if ($url =~ m/yesthatjwz/) { use Devel::StackTrace; print STDERR "\n\n#####\n" . Devel::StackTrace->new->as_string; } my $t = strftime("%l:%M:%S %p", localtime); if ($post_args) { print STDERR blurb() . "$t: POST $url\n" if ($verbose > 3); $res = $ua->post ($url, Content_Type => 'form-data', Content => $post_args); } else { print STDERR blurb() . "$t: GET $url\n" if ($verbose > 3); $res = $ua->get ($url); } my $ret = ($res && $res->code) || 'null'; my $json = ($res && $res->content) || ''; rate_limit_throttle ($url, $res); if ($json && $json =~ m/^access_token=/s) { $obj = $json; # You complete asshats! Non-JSON response! } elsif ($json) { eval { my $j = JSON::Any->new; $obj = $j->jsonToObj ($json); }; } if ($ret ne '200') { $err = "Error $ret: $description: "; if ($obj && ref($obj) eq 'HASH' && $obj->{error} && $obj->{error}->{message}) { $err .= $obj->{error}->{message}; if ($obj->{error}->{error_user_msg}) { $err .= ". " . $obj->{error}->{error_user_title} . ": " . $obj->{error}->{error_user_msg}; } } elsif ($obj && ref($obj) eq 'HASH' && $obj->{error_msg}) { $err .= $obj->{error_msg}; } else { $err .= $res->message; } $obj = undef; $retries = 0 if ($ret eq '404'); # Believe 404 # Believe this 400 error too. $retries = 0 if ($err && $err =~ m/does not have the capability/si); $retries = 0 if ($err && $err =~ m/does not have permission/si); $retries = 0 if ($err && $err =~ m/due to missing permissions/si); $retries = 0 if ($err && $err =~ m/\bdeprecated\b/si); $retries = 0 if ($err && $err =~ m/Session has expired/si); $retries = 0 if ($err && $err =~ m/you're temporarily blocked/si); $retries = 0 if ($err && $err =~ m/activity related to copyright/si); $retries = 0 if ($err && $err =~ m/App must be on whitelist/si); $retries = 0 if ($err && $err =~ m/API is no longer available/si); $retries = 0 if ($err && $err =~ m/account is temporarily locked/si); $retries = 0 if ($err && $err =~ m/too large to download/si); $retries = 0 if ($err && $err =~ m/image format unknown/si); $retries = 0 if ($err && $err =~ m/user (access )?is restricted/si); $retries = 0 if ($err && $err =~ m/restricted from/si); # "The media is not ready for publishing, please wait for a moment" $delay += 3 if ($err && $err =~ m/media is not ready/si); # $retries = 0 if ($err && $err =~ m/Application request limit reached/si); # if ($err && $err =~ m/Application request limit reached/si) { # # https://developers.facebook.com/docs/graph-api/overview/rate-limiting/ # # I think this means we only get 200 requests per hour?? # # And even trying to connect to see if we're un-throttled makes the # # throttling worse. # # # # Graph of requests: # # https://developers.facebook.com/apps/72575175403/rate-limit-details/app/?business_id=10152592064456086 # # That showed a lot for "Event/admins" # # # my $n = 60 * 60; # my $t = strftime("%l:%M:%S %p", localtime); # print STDERR blurb() . "$t: rate throttled; " . # $res->header('X-App-Usage') . # " -- sleeping for $n secs...\n" # ;#### if ($verbose); # sleep ($n); # } # No, it's ok to retry posts, because of the "500" check below. #$retries = 0 if ($post_args); # Never retry when posting } last if defined ($obj); sleep ($delay) if ($retries); $delay++; } if (!$obj || $err) { $err = "$description failed" unless $err; if (! $ignore_errors) { if ($fb_first_time_p && $err =~ m/^Error 4\d\d/si && $err !~ m/request limit reached/si) { # A 500 error means something other than bogus token. # So does "request limit". $err .= ("\n" . "\t\t This might mean your access_token is bogus.\n" . "\t\t Check your ~/.APPNAME-facebook-pass file.\n" . "\t\t"); } $err .= " after $i tries in " . (time() - $start) . " secs -- $url"; # . "\n### " . Dumper($post_args); # Sometimes Facebook returns 500 but posts the thing anyway. # So we have to assume that it posted, I guess. # # But sometimes the 500 error comes from LWP itself, meaning # connection timed out or refused. And in that case, it's safe # for us to retry. # if ($err =~ m/^Error 500/si && $err !~ m/Can't connect to .*facebook\.com/si && $description eq 'posting') { print STDERR (blurb() . " Error 500! Praying it posted anyway!\n" . "$err\n\n" . "\"" . ($post_args->{message} || $post_args->{description} || $post_args->{caption} || '') . "\"\n----------------------\n\n"); my %ret = ( 'id' => 'err_500' ); return \%ret; } if ($err =~ m/^Error 400: .*deemed abusive/si) { # This means we tried to upload a video and it was rejected by # content ID. print STDERR blurb() . "$err\n"; return undef; } error ($err); } return undef; } $fb_first_time_p = 0; return $obj; } # Duplicated in hacks/facebook-rss.pl # Duplicated in facebook/cover-sync.pl # my $printed_rate_limit_p = 0; sub rate_limit_throttle($$) { my ($url, $res) = @_; # https://developers.facebook.com/apps/72575175403/rate-limit-details/app/ #### Jan 2021: saw this error, not caught by this function: #### Error 500: "Please reduce the amount of data you're asking #### for, then retry your request" after 10 tries in 114 secs. #### Yay. my $usage = $res->header('X-App-Usage') || ''; my ($call) = ($usage =~ m/"call_count":(\d+)/si); my ($cpu) = ($usage =~ m/"total_cputime":(\d+)/si); my ($total) = ($usage =~ m/"total_time":(\d+)/si); $call = 0 unless defined($call); $cpu = 0 unless defined($cpu); $total = 0 unless defined($total); my $max = 50; # Sleep if any of the numbers are higher than this my $n = $call > $cpu ? $call : $cpu; $n = $n > $total ? $n : $total; if ($n > $max) { my $sleep = ($n - $max) * 10; my $max2 = 60 * 5; # Sleep no more than this many seconds per URL $sleep = $max2 if ($sleep > $max2); my $t = strftime("%l:%M:%S %p", localtime); my $u2 = $url; $u2 =~ s/access_token.*$/.../s; print STDERR blurb() . "$t: $u2\n"; print STDERR blurb() . "$t: rate limit: $usage;" . " sleeping for $sleep secs...\n\n"; $printed_rate_limit_p = 1; sleep ($sleep) unless $debug_p; } elsif ($printed_rate_limit_p) { my $t = strftime("%l:%M:%S %p", localtime); $usage = 'no X-App-Usage header' unless $usage; print STDERR blurb() . "$t: rate limit ended: $usage\n\n"; $printed_rate_limit_p = 0; } } # Like fb_load but caches the result forever. # Only the URL is used as the cache key, not the post_args. # my %fb_load_cache; sub fb_load_cached($$$;$$) { my ($description, $auth, $args, $post_args, $ignore_errors) = @_; my $key = $args; $key =~ s/[?&]access_token=[^?&]*//s; # No longer needed? my $old = $fb_load_cache{$key}; if (defined($old)) { print STDERR blurb() . "CACHE HIT: $key\n" if ($verbose > 3); return $old; } $old = fb_load ($description, $auth, $args, $post_args, $ignore_errors); $fb_load_cache{$key} = $old if defined($old); return $old; } # Returns the ID of a page or user, or undef. # sub fb_name_to_id($$) { my ($name, $auth) = @_; return $name if ($name =~ m/^\d+$/s); # %page_ids can contain a page ID so that we don't have to look it # up every time, since we're now living in a dystopia where literally # every http request has to be counted. # my $id = $page_ids{$name} || ''; return $id if $id; my $ret = fb_load_cached ("page id", $auth, "$name?fields=id,name"); $id = ($ret ? $ret->{id} : undef); return $id; } # If we're posting to a page, we have to trade in our user-token for a # page-token or else things post as "Jamie Zawinski" instead of "DNA Lounge". # my $inside_fb_swap_token = 0; sub fb_swap_token($) { my ($auth) = @_; return if ($inside_fb_swap_token || !$auth); $inside_fb_swap_token++; my ($user, $token, $page_token, $secret, $proof) = @$auth; if (defined($page_token)) { # Already checked and possibly swapped $inside_fb_swap_token--; return; } @$auth = ($user, $token, $token, $secret, $proof); # Don't loop my $page = $user; my $page_id = fb_name_to_id ($page, $auth); if (! $page_id) { # This probably isn't a page. $inside_fb_swap_token--; return; } # We need to use our user access_token to get a page access_token, # by finding that page in the list of all pages we have permission on. # These tokens are good for up to 60 days, but are not permanent, so # cacheing them is hard. And of course I'm only thinking about doing # that because of rate limiting. Thanks, Facebook! # my $ret = fb_load_cached ("account token", $auth, "me/accounts" . # Name isn't needed but helpful for debugging "?fields=name,id,access_token"); error ("can't find account tokens") unless ($ret && $ret->{data}); my $token2 = undef; foreach my $a (@{$ret->{data}}) { next unless ($a->{id} eq $page_id); $token2 = $a->{access_token}; last; } error ("unable to find token for $page") unless $token2; print STDERR blurb() . "swapped token to $page\n" if ($verbose > 2); error ("no secret?") unless $secret; $proof = Digest::SHA::hmac_sha256_hex ($token2, $secret); # Now we have the new token @$auth = ($user, $token, $token2, $secret, $proof); $inside_fb_swap_token--; } # Returns the Instagram ID assocated with this Facebook page. # sub instagram_id($$) { my ($user, $auth) = @_; my $page_id = fb_name_to_id ($user, $auth); my $ret = fb_load ("ig id", $auth, "$page_id?fields=instagram_business_account"); return undef unless $ret; return undef unless $ret->{instagram_business_account}; return $ret->{instagram_business_account}->{id}; } # Given a simple $post hash, convert it to a Facebook image attachment hash. # sub fb_make_attachment($$$) { my ($user, $post, $fb_auth) = @_; my $private_p = $debug_p; # If we have not yet swapped to a page token, do it now, lazily. fb_swap_token ($fb_auth); my ($user0, $token, $page_token, undef, undef) = @$fb_auth; my $page_p = ($token ne $page_token); # "Posts where the actor is a page cannot also include privacy". my $privacy = ($page_p ? undef : $private_p ? 'SELF' : 'ALL_FRIENDS'); my $txt = $post->{txt}; my $orig_txt = $post->{orig_txt}; my $href = $post->{href}; my $ohref = $post->{orig_href}; my $geo = $post->{geo}; my $ac = $post->{add_comment}; my @images = @{$post->{images}}; my @img_ids = $post->{attached_media} ? @{$post->{attached_media}} : (); @images = () if @img_ids; my $otxt = $txt; # Omit $href inside $txt $txt =~ s/\s*\Q$href\E\s*/ /gs if ($href && !@img_ids); # Allow backslashed newlines and such on command line. $txt =~ s/\\t/\t/gs; $txt =~ s/\\r/\r/gs; $txt =~ s/\\n/\n/gs; $txt =~ s/\r\n/\n/gs; $txt =~ s/\r/\n/gs; $txt =~ s/^\s+|\s+$//gs; $post = {}; $post->{add_comment} = $ac if $ac; # bubble up #$txt =~ s/\s+/ /gs; $txt =~ s/^\s+|\s+$//gs; # If it's an RT with images, just post the image. # But include the URL in the text, e.g., an RT of someone's blog post # that ends up with an uploaded thumbnail image. # if ($href && $txt =~ m/\bRT[:\s]+\@[a-zA-Z]/s && @images) { $txt .= "\n$href"; $href = undef; } # Try to find a thumbnail via meta tags if we don't have an image. # #### 26-Nov-2022, fuck it. Let Facebook figure it out themselves. # if ($href && !@images && !@img_ids) { # my ($img, $link, $name, $desc, $loc, $shortlink, $videos) = # page_metadata ($href); # push @images, [$img, ''] if @images; # } # Convert @images from [img, alt] to just img. # If there's no text, gather up the various alt texts. { my @alts = (); foreach my $IA (@images) { my ($img, $alt) = @$IA; $IA = $img; push @alts, $alt if $alt; } $txt = join("\n", @alts) if (!$txt && @alts); } # Can't post a link along with a gallery of uploaded unpublished images. # $href = undef if (@img_ids); # Omit all Twitter and Instagram URLs. They are worthless. # Unless we are posting a link to a video. # if (! (@images && $images[0] =~ m/\.mp4(\?|$)/ && !$facebook_upload_videos_p)) { if ($href && ($href =~ m@^https?://([-_a-z]+\.)?(twitter|twimg)@si || $href =~ m@^https?://[^/]*\b(instagram\.com/p/)@si)) { print STDERR blurb() . "omitting $href\n" if ($verbose > 1); $href = undef; } } if (($orig_txt || '') =~ m/\bpatreon\.com\/dnalounge\b/si) { $txt = $orig_txt; # Leave it alone } # Construct the Facebook post hash. # $post->{message} = $txt if $txt; $post->{picture} = $images[0] if @images; $post->{link} = $href if $href; $post->{privacy} = JSON::Any->new->objToJson ({ value => $privacy }) if $privacy; # If there's an image there can't also be a link. Doing that overwrites # the image post with something that looks like an event-share. delete ($post->{link}) if ($post->{picture}); # If a video ended up in the picture (which doesn't work) and the video # is from Instagram or Twitter, and the text of the twit contains # a URL, delete the image and post a link-share of that URL instead. # (orig_href is so that this works if it was a GIF posted to Twitter with # no text at all.) # Unless it's a DNA URL. # my $msg_url = (($post->{message} || '') =~ m@\b(https?://[^\s<>\"\']+)@si ? $1 : ($href || $ohref || '') =~ m@\b(https?://[^\s<>\"\']+)@si ? $1 : ''); $msg_url = '' if ($msg_url =~ m@^https?://www\.dnalounge\.com/@si); if (!$facebook_upload_videos_p && $post->{picture} && $post->{picture} =~ m@\.(mp4|mov|m4v)@s && $post->{picture} =~ m@^https?://[^/]*(instagram|twitter|twimg)@si && $msg_url) { $post->{link} = $1; delete $post->{picture}; } # If there is a Twitter picture and also a Twitter link, they are the same. # Likewise Instagram. Pretend the link doesn't exist, which means we will # upload the photo or video to Facebook. # if ($post->{picture} && $post->{link} && $post->{picture} =~ m@^https?://[^/]*(twitter|twimg|instagram)@si && $post->{link} =~ m@^https?://[^/]*(twitter|twimg|instagram)@si) { delete $post->{link}; } # If we uploaded a video, then omit the link. # It is not shown, but if it happened to a link to a Facebook event, # the link takes priority and the video doesn't show up! # if ($post->{picture} && $post->{picture} =~ m/\.mp4(\?|$)/s) { delete $post->{link}; } # If there is an image but no URL, upload the image directly. # That means this is not a link to a page on the DNA site, but is # instead, e.g., a photo that was directly uploaded to Twitter. # if ($post->{picture} && !$post->{link}) { $post = fb_convert_link_to_upload ($user, $post, $otxt, $fb_auth); } # If there is still a picture but no link, that's an error, so add a link. # if ($post->{picture} && !$post->{link}) { my ($venue_name, $url_base, $web_root) = site_info ($user); $post->{link} = $url_base; } # If attached_media is set, they are the Facebook IDs of unpublished # photos that we have just uploaded, that should be attached to this post. # if (@img_ids) { delete $post->{source}; my $i = 0; foreach my $img_id (@img_ids) { $post->{"attached_media[$i]"} = JSON::Any->new->objToJson ({ 'media_fbid' => $img_id }); $i++; } } # Some late reformatting of the text. # Everything uses 'message' except video uploads which require 'description'. # $txt = $post->{message} || $post->{description} || ''; $txt = html_unquote ($txt); # Facebook ignores newlines. (does it?) $txt =~ s/^\s+|\s+$//gs; # $txt =~ s/\s+/ /gs; # Lose "my-name: " at the beginning of the text. $txt =~ s/^(@?(\Q$user\E|\Q$user\E)[:,\s]+)+//si; # Don't needs a bunch of dots at the end, as many Instagrammers do. $txt =~ s/\s+\.+[\s.]*$//gs; # 1000 character limit. Truncate. # But I have seen 398 succeed, and 438 fail with "400 invalid parameter". # So maybe the limit is no longer 1000? $txt =~ s/^(.{415}).*$/$1 .../s; if (defined($post->{message})) { $post->{message} = $txt; } elsif (defined($post->{description})) { $post->{description} = $txt; } error ("only a URL in post:\n\n" . Dumper($post) . "\n\nVia: $otxt") if (!$post->{source} && $post->{message} && $post->{message} =~ m@^http[^\s]+$@si); error ("twit ends in 'Error':\n\n" . Dumper($post) . "\n\nVia: $otxt") if ($post->{message} && $post->{message} =~ m@ Error\s*$@s); error ("a video ended up as the 'picture':\n\n" . Dumper($post) . "\n\nVia: $otxt") if ($post->{picture} && $post->{picture} =~ m@\.(mp4|mov|m4v)@s); return $post; } # Instead of having 'picture' point to a URL, upload the raw image to FB. # sub fb_convert_link_to_upload($$$$) { my ($user, $post, $otxt, $auth) = @_; my $url = $post->{picture} || $post->{link}; error ("no picture") unless $url; my ($ext) = ($url =~ m@\.([^./]+)$@s); my $ct = ext_to_ct ($ext); $ct = 'video/mp4' if ($url =~ m@^https?://([^/]*\.youtube\.com|youtu\.be)/@si); if (!$facebook_upload_videos_p && $ct =~ m/^video/si) { print STDERR blurb() . "skipping video upload of $url\n" if ($verbose > 1); return $post; } $ct = undef; my $tmp = $ENV{TMPDIR} || '/tmp'; $tmp =~ s@/+$@@s; my $base = sprintf("%s/fbmirror-%08x", $tmp, rand(0xFFFFFFFF)); $base .= "-$user" if ($user); my $file = undef; my $name = undef; if ($url =~ m@\.(m3u8)?$@si) { $file = $url; $ct = ext_to_ct ($1); } elsif ($url =~ m@^https?://@si) { $name = $url; $name =~ s@[?&].*$@@s; $name =~ s@^.*/@@s; $name = url_unquote ($name); $name =~ s/(:[a-z]+)$//s; # ":large" $name =~ s/[^-_.a-z\d ]//gsi; $name = 'image' unless $name; $file = "$base-$name"; rm_atexit ($file); my $ua = $LWP::Simple::ua; $ua->agent("$progname/$version"); my $res; print STDERR blurb() . "saving $url to $file\n" if ($verbose > 1); $res = $ua->get ($url, ':content_file' => $file); my $ret = ($res && $res->code) || 'null'; error ("$url failed: $ret") unless ($ret eq '200'); $ct = $res->header ('Content-Type') || 'application/octet-stream'; $ct =~ s/[;\s].*$//s; $ct = lc($ct); # Give the tmp file the proper extension, for resize.pl and ffmpeg. my $ext = ct_to_ext ($ct); my ($oext) = ($file =~ m@\.([^./]+)$@s); if ($ext && $ext ne ($oext || '')) { my $f2 = $file; $f2 =~ s@\.([^./]+)$@@s; $f2 .= ".$ext"; rm_atexit ($f2); rename ($file, $f2) || error ("mv $file $f2: $!"); print STDERR blurb() . "mv $file $f2\n" if ($verbose > 1); $file = $f2; } } else { $file = $url; error ("$file does not exist") unless (-f $file || $debug_p); $ct = ext_to_ct ($file); } if (! $name) { $name = lc($file); $name =~ s@^.*/@@s; $name =~ s/[^-_.a-z\d ]//gs; $name = 'image' unless $name; } # Convert .m3u8 to .mp4 $ct = 'video/mp4' if ($ct =~ m@^application/(x-)?mpegurl@si); error ("$url: not an image or video: $ct") unless ($ct =~ m@^(image|video)/@s); my @cmd = ('resize.pl', '--preset', 'facebook', '-q'); push @cmd, ('-' . ('v' x ($verbose-1))) if ($verbose > 2); push @cmd, $file; my $file2 = "$base-$name-2." . ct_to_ext ($ct); rm_atexit ($file2); push @cmd, ('--out', $file2); safe_system (@cmd) if (!$debug_p || $file =~ m/^https?:/ || -f $file); $file2 = $file unless (-f $file2); # Might have needed no changes # We're not actually favoriting, we're mirroring... $otxt =~ s/^Favorited a video: //s; # The POST needs to be: # # Content-Type: multipart/form-data; boundary=... # ... # Content-Disposition: form-data; name="source"; filename="..." # Content-Type: image/jpeg # $post->{source} = [ $file2, $name, 'Content-Type' => $ct ]; # These fields are not allowed in uploads to /photos or /videos endpoints: delete $post->{link}; delete $post->{picture}; if ($post->{message} && $ct =~ m@^video/@s) { # Videos use "description" instead of "message". Sigh. $post->{description} = $post->{message}; delete $post->{message}; # Maybe it's better for videos to have titles as well? (Or instead of?) $post->{title} = $post->{description}; $post->{title} =~ s/^(.{80}).+$/$1 .../si; # Actual limit is 255 } return $post; } # Like fb_load but does a multi-step upload to send videos in chunks. # https://developers.facebook.com/docs/graph-api/video-uploads # sub fb_load_chunked($$$;$$) { my ($description, $auth, $url, $args0) = @_; # Start my ($user) = ($url =~ m@^([^/?&]+)@s); my $uid = fb_name_to_id ($user, $auth); $url = "https://graph-video.facebook.com/v2.7/$uid/videos"; my $ct = $args0->{source}[3]; my $file = $args0->{source}[0]; my $size = (stat($file))[7]; delete $args0->{source}; # Don't upload it again! my $args = { upload_phase => 'start', file_size => $size }; my $ret = fb_load ("$description start", $auth, $url, $args); if (! defined($ret)) { # If fb_load returned undef when we didn't specify ignore_errors, # it means that we tried to upload a video and it was rejected by # content ID. So just skip this Facebook video, and go on to try # and upload it to Instagram, and do the following stuff. print STDERR blurb() . "FB $user: skipping video $file\n"; return undef; } #error ("chunk start: post failed") unless $ret; my $sess = $ret->{upload_session_id} || error ("upload: no session"); my $vid = $ret->{video_id} || error ("upload: no ID"); my $start = $ret->{start_offset} || 0; my $end = $ret->{end_offset} || error ("upload: no end"); # Transfer my $tmp = $ENV{TMPDIR} || '/tmp'; $tmp =~ s@/+$@@s; my $tmp_file = sprintf("%s/fbmirror-%08x", $tmp, rand(0xFFFFFFFF)); open (my $in, '<:raw', $file) || error ("$file: $!"); rm_atexit ($tmp_file); # To upload the file in chunks, we open the video file for reading; # write N bytes of it to a tmp file; send that file as a form upload; # then overwrite that file for the next chunk; and repeat. # Since LWP has no way of saying "send only this range in the file". my $n = 0; while (1) { unlink ($tmp_file); open (my $out, '>:raw', $tmp_file) || error ("$tmp_file: $!"); seek ($in, $start, 0) || error ("unable to seek to $start in $file: $!"); my $buf = ''; my $bufsiz = 1024 * 100; my $remaining = $end - $start; my $bytes_read = 0; while ($remaining > 0) { my $size = ($remaining < $bufsiz ? $remaining : $bufsiz); my $n = sysread ($in, $buf, $size); last if ($n <= 0); $bytes_read += $n; $remaining -= $n; print STDERR "$tmp_file: copied $n bytes ($bytes_read)\n" if ($verbose > 6); (print $out $buf) || error ("write: $!"); } close ($out); print STDERR blurb() . "wrote $tmp_file," . " $start-$end = $bytes_read\n" if ($verbose > 5); $args = { upload_phase => 'transfer', upload_session_id => $sess, start_offset => $start, video_file_chunk => [ $tmp_file, undef, 'Content-Type' => $ct ] }; # Ignore errors when uploading a chunk. We'll just retry. $ret = fb_load ("$description transfer $n", $auth, $url, $args, 1); unlink ($tmp_file); if (!$ret || !$ret->{end_offset}) { #error ("chunk transfer $n: post failed"); print STDERR blurb() . "$description:" . " chunk transfer $n failed\n" if ($verbose); } else { $start = $ret->{start_offset} || error ("upload $n: no start"); $end = $ret->{end_offset} || error ("upload $n: no end"); print STDERR blurb() . "upload chunk $n ok" . sprintf(" (%d%%)\n", 100 * $start / $size) if ($verbose > 2); last if ($start == $end); } $n++; error ("$description: way too many chunks: $n") if ($n > 500); } close ($in); # Finish $args0->{upload_phase} = 'finish'; $args0->{upload_session_id} = $sess; delete $args0->{place}; # Attaching a location causes a 400 error! $ret = fb_load ("$description finish", $auth, $url, $args0); error ("chunk finish: post failed") unless ($ret && $ret->{success}); # They don't give us the post ID; the post happens some time later, after # the video has been processed. Do this just so the caller, facebook_post_1, # doesn't fail its error check. # $ret->{id} = '...pending...' unless ($ret->{id}); return $ret; } sub facebook_post_1($$$$$$) { my ($user, $userid, $post, $fb_auth, $publish_p, $preformatted_p) = @_; my $opost = $post; $post = fb_make_attachment ($user, $post, $fb_auth) unless ($preformatted_p); return undef unless ($post); $post->{published} = 0 unless $publish_p; # No undefs in the hash. foreach my $k (keys %$post) { # delete $post->{$k} $post->{$k} = '' unless defined($post->{$k}); } # Don't post comments on unpublished images. my $add_comment = $publish_p ? $post->{add_comment} : undef; if ($post->{add_comment}) { $opost->{add_comment} = $post->{add_comment}; # bubble up delete $post->{add_comment}; } if (!$publish_p && $post->{picture} && !$post->{url}) { # I guess 'picture' changed to 'url' for unpublished images? $post->{url} = $post->{picture}; delete $post->{picture}; # And now links aren't showing up, so let's put them in caption. if ($post->{link} && !$post->{caption}) { $post->{caption} = $post->{link}; delete $post->{link}; } } if ($debug_p) { # Convert these to objects for printing. foreach my $f (qw{properties actions privacy}) { my $s = $post->{$f}; if ($s) { $s = "" . $s; utf8::encode ($s); # Unpack wide chars to multi-byte UTF-8. $post->{$f} = JSON::Any->new->jsonToObj ($s); } } $post->{source}[5] =~ s/^(.{10}).*$/$1 .../s if ($post->{source} && $post->{source}[5]); print STDERR "\nWould have posted to $user ($userid):\n"; print STDERR Dumper($post), "\n\n"; if ($add_comment) { # Still in debug mode my ($eid, $txt) = @$add_comment; # facebook_post_comment ($user, $userid, $eid, 1, $txt, $fb_auth); } return undef; } my $ct = $post->{source} ? $post->{source}[3] : ''; my $url = ($post->{source} ? ($ct =~ m@^video/@s ? "$userid/videos" : "$userid/photos") : (!$publish_p ? "$userid/photos" : "$userid/feed")); my $ret = ($ct =~ m@^video/@s ? fb_load_chunked ("posting", $fb_auth, $url, $post) : fb_load ("posting", $fb_auth, $url, $post)); #return undef unless defined($ret); error ("post failed") unless $ret; my $post_id = $ret->{id}; error ("no id after post") unless $post_id; my $post_url = $post_id; $post_url =~ s@_@/posts/@s; $post_url = 'https://www.facebook.com/' . $post_url; print STDERR blurb() . "FB post: $post_url\n" if ($verbose); if ($add_comment) { my ($eid, $txt) = @$add_comment; # # 18-Jun-2022: This doesn't work any more, since I had to re-authorize # the app as Devon instead of jwz. # # facebook_post_comment ($user, $userid, $eid, 1, $txt, $fb_auth); } return $post_id; } sub facebook_post($$$$) { my ($user, $post, $fb_auth, $preformatted_p) = @_; my $userid = fb_name_to_id ($user, $fb_auth); error ("unable to find uid of Facebook user \"$user\"") unless $userid; return facebook_post_1 ($user, $userid, $post, $fb_auth, 1, $preformatted_p) if ($preformatted_p); my @images = @{$post->{images}}; # When uploading images to Facebook, put the URL of the page in the post. # But omit Twitter URLs. Unless it's an RT. if (@images > 0 && $post->{href}) { if ($post->{txt} =~ m/\bRT[:\s]+\@[a-zA-Z]/s || $post->{href} !~ m@^https?://([-_a-z]+\.)?(twitter|twimg)@si) { $post->{txt} .= "\n" . $post->{href}; } $post->{txt} =~ s/^\s+|\s+$//gs; $post->{orig_href} = $post->{href}; } if (@images <= 1) { # 1 or 0 images: 1 post # Force an upload instead of posting a link. (URL is in txt, per above.) delete ($post->{href}) if (@images == 1); return facebook_post_1 ($user, $userid, $post, $fb_auth, 1, 0); } else { # 2 or more images: upload images, then post my %img_links; my @img_links; my $i = 1; my @img_ids = (); foreach my $IA (@images) { my $p2 = { %$post }; $p2->{images} = [ $IA ]; delete ($p2->{href}); # Force an upload instead of posting a link # Nov 2022: If the sub-image has alt text, use that as its caption. # Otherwise, use the same text as on the parent post. my $alt = $IA->[1]; $p2->{txt} = $alt if ($alt); print STDERR blurb() . "posting unpublished image $i: " . Dumper($p2) . "\n" if ($verbose > 1); my $img_id = facebook_post_1 ($user, $userid, $p2, $fb_auth, 0, 0); # If these images have a URL as their alt text, store those. my $img_alt = $IA->[1]; if ($img_alt && $img_alt =~ m@^https?:[^\s]+$@s) { push @img_links, $img_alt unless $img_links{$img_alt}; $img_links{$img_alt} = 1; } # If the unpublished post wanted a comment, save it. $post->{add_comment} = $p2->{add_comment} if ($p2->{add_comment}); $img_id = "DEBUG_$i" if ($debug_p && !$img_id); if (defined($img_id)) { # Post the link as a comment on this sub-image post #### Post $alt here? # facebook_post_comment ($user, $userid, $img_id, 0, # $post->{href}, $fb_auth); push @img_ids, $img_id; } $i++; # First guess: Facebook sorts the images in the multi-post # image by "created_time" or "updated_time", but only stores that in # one-second resolution. So let's delay between each upload to make # sure they fall on a different second tick, and show up in order. # Verdict: nope. They all end up with identical timestamps, which # are the same as the stamps on the container post. # Second guess: insert "backdated_time" on the post. Nope. # Third guess: set created and updated. Nope. # I guess images just show up in a random-assed order. Hooray. # sleep (2); } $post->{attached_media} = \@img_ids; print STDERR blurb() . "posting multiple images: " . Dumper($post) . "\n" if ($verbose > 1); my $id = facebook_post_1 ($user, $userid, $post, $fb_auth, 1, 0); # Post a comment on the multi-image post for each of the links that # the images point to. Hopefully these will show up as a link share # in the comments? # # 18-Jun-2022: This doesn't work any more, since I had to re-authorize # the app as Devon instead of jwz. # # if ($id || $debug_p) { # foreach my $url (@img_links) { # facebook_post_comment ($user, $userid, ($id || 'DEBUG'), 0, # $url, $fb_auth); # } # } return $id; } } # Try to post, but if that fails due to us being on super secret double video # probation, post again as a link instead of an upload. # sub facebook_post_retry($$$) { my ($user, $post, $fb_auth) = @_; eval { error ("pretending to be temporarily blocked") if ($debug_p); facebook_post ($user, $post, $fb_auth, 0); }; if ($@) { my $err = $@; if ($facebook_upload_videos_p && $err =~ m/activity related to copyright|temporarily blocked/si) { # Sigh. Disable video uploading, and just post it as a link. $err =~ s/\n$//s; print STDERR blurb() . "$err; retrying without video...\n" if ($verbose); # $verbose++ unless $verbose; $facebook_upload_videos_p = 0; facebook_post ($user, $post, $fb_auth, 0); $facebook_upload_videos_p = 1; print STDERR blurb() . "retry succeeded.\n\n" if ($verbose); } else { error ($err); } } } sub instagram_upload($$@) { my ($user, $txt, @files) = @_; # https://developers.facebook.com/docs/instagram-api/guides/content-publishing # Posting requires "instagram_basic" and "instagram_content_publish". # Stories are unsupported. utf8::decode($txt); # Parse multi-byte UTF-8 into wide chars. $txt = html_unquote ($txt); my $auth = load_keys ($user); my $ig_id = instagram_id ($user, $auth); error ("no Instagram ID for $user") unless $ig_id; #### Would like to replace "instagram-upload.pl --rss --likes --tags" #### but this gets permission denied even with "instagram_manage_insights": #### https://developers.facebook.com/docs/instagram-api/reference/ig-user/tags #{ # my $ret = fb_load ("ig rss", $auth, "$ig_id/tags"); # print STDERR Dumper($ret); # exit(1); #} # Create "containers" for each image. # You first have to put the image/video on a public web server # and point FB at that, because we all live in hell now. # my @img_ids = (); foreach my $P (@files) { my ($file, $alt) = @$P; my ($ext) = ($file =~ m@\.([^/.]+)$@si); error ("no extension: $file") unless $ext; $ext = lc($ext); my $name = sprintf("tmp-%08x.%s", rand(0xFFFFFFFF), $ext); my $path = "$instagram_staging_dir/$name"; my $url = "$instagram_staging_url/$name"; rm_atexit ($path); my @cmd = ('cp', '-p', $file, $path); safe_system (@cmd); # Sanity-check webroot. my $ua = $LWP::Simple::ua; $ua->agent("$progname/$version"); my $res = $ua->head ($url); error ("writing to $path did not produce a readable URL at $url") unless ($res && $res->is_success); @cmd = ('resize.pl', '--preset', 'instagram', $path); push @cmd, '-q' unless ($verbose); safe_system (@cmd); my $post = ($ext =~ m/(mp4|m4v|mov)/si ? { media_type => 'VIDEO', video_url => $url, caption => $txt, thumb_offset => 1000 * 12, # thumb frame - defaults to 0 } : { image_url => $url, caption => $txt, }); my $fb_page_id = fb_name_to_id ($user, $auth); $post->{location_id} = $fb_page_id if ($fb_page_id); my $ret = fb_load ("ig container", $auth, "$ig_id/media", $post); my $id = $ret->{id} || error ("failed to upload $file"); push @img_ids, $id; # Disable comments, ignore errors. # #### This appears to not be working. $post = { comment_enabled => 0 }; $ret = fb_load ("ig disable", $auth, "$id", $post, 1); } # Wrap in a carousel if there is more than one image. # my $cid; if (scalar (@img_ids) == 1) { $cid = $img_ids[0]; } else { my $post = { media_type => 'CAROUSEL', caption => $txt, children => join (',', @img_ids) }; my $ret = fb_load ("ig carousel", $auth, "$ig_id/media", $post); $cid = $ret->{id} || error ("failed to make carousel"); # Disable comments, ignore errors. $post = { comment_enabled => 0 }; $ret = fb_load ("ig disable", $auth, "$cid", $post, 1); } error ("debug: not publishing $cid") if ($debug_p); # Publish the image or carousel. # my $post = { creation_id => $cid }; # This retries several times with "The media is not ready for publishing, # please wait for a moment" my $ret = fb_load ("ig container", $auth, "$ig_id/media_publish", $post); my $id = $ret->{id} || error ("failed to publish $cid"); # The ID is numeric but the URL is like /p/CpBRQdNj659/ and not returned. # Instagram calls that a "shortcode". Don't really need the URL, I guess. # my $url = "https://instagram.com/p/$id"; print STDERR "$progname: posted $id\n" if ($verbose); } sub facebook_upload($$@) { my ($user, $txt, @files) = @_; utf8::decode($txt); # Parse multi-byte UTF-8 into wide chars. $txt = html_unquote ($txt); error ("max of 4 files allowed") if (@files > 4); my $fb_auth = load_keys ($user); # If the text ends with a URL, separate that out. # The URL may optionally followed by hashtags. # If it's inline, leave it where it is. # If there are additional earlier URLs, leave those in the text. # my $url = undef; if ($txt =~ m@^ (.*) \b (https?://[^\s]+) ( \s* (?: \s* \#[^\s]+ )* \s* ) $ @six) { my ($head, $u, $hashtags) = ($1, $2, $3); $txt = $head . $hashtags; $url = $u; } $txt =~ s/^\s+|\s+$//gs; my $post = { txt => $txt, href => $url, images => \@files, }; facebook_post_retry ($user, $post, $fb_auth); } sub facebook_delete($$$$) { my ($user, $instagram_p, $from, $to) = @_; error ("FB IG API does not support deletion") if ($instagram_p); # https://developers.facebook.com/docs/instagram-api/reference/ig-media # "Deleting: This operation is not supported" my ($ofrom, $oto) = ($from, $to); $from = parsedate ($from) || error ("unparsable date: $from"); $to = parsedate ($to) || error ("unparsable date: $to"); error ("from > to: $ofrom > $oto") if ($from > $to); my $auth = load_keys ($user); my $uid = ($instagram_p ? instagram_id ($user, $auth) : fb_name_to_id ($user, $auth)); foreach my $kind ($instagram_p ? ('media') : ('posts', 'videos')) { # Can only get 100 at a time; limited to 600 per year? my $url = ($instagram_p ? "$uid/media?fields=caption,timestamp,permalink" . "&limit=100" : "$uid/$kind?fields=message,created_time,permalink_url" . "&limit=100"); # "Timeline photos" album # '/481642976085/photos?fields=name,caption,created_time' my $n = 1; my $done = 0; my $seen = 0; my $deleted = 0; while ($url) { print STDERR "$progname: loading $kind page $n...\n" if ($verbose); my $ret = fb_load ("posts", $auth, $url); foreach my $p (@{$ret->{data}}) { my $id = $p->{id}; my $txt = ($p->{message} || $p->{description} || $p->{story} || $p->{caption} || $p->{name} || '?'); my $date = str2time($p->{updated_time} || $p->{created_time} || $p->{timestamp}); my $link = $p->{permalink_url} || $p->{permalink}; $link = "https://www.facebook.com$link" if ($link =~ m@^/@s); $txt =~ s/^(.{40}).+$/$1.../s; $txt =~ s/\n/\\n/gs; my $ds = $date ? strftime("%d-%b-%Y", localtime($date)) : 'ERROR'; $seen++; if (! $date) { error ("unparsable: " . Dumper($p)); } elsif ($link =~ m@/events/|substory_index@) { # Can't delete these print STDERR "$progname: keeping event: $ds: $txt $link\n" if ($verbose); } elsif ($date >= $to) { print STDERR "$progname: keeping: $ds: $txt $link\n" if ($verbose); } elsif ($date < $from) { print STDERR "$progname: done - reached $ds ($ofrom)\n" if ($verbose); $done = 1; } elsif ($debug_p) { print STDERR "$progname: would have deleted: $ds: $txt $link\n" if ($verbose); $deleted++; } else { my $ret2 = fb_load ("delete", $auth, "$id", { method => 'delete' }, 2); if ($ret2 && $ret2->{success}) { print STDERR "$progname: deleted: $ds: $txt $link\n" if ($verbose); $deleted++; } else { print STDERR "$progname: deleting FAILED: $ds: $txt $link\n"; } } last if $done; } last if $done; last unless defined ($ret->{paging}); $url = $ret->{paging}->{next}; $n++; } print STDERR "$progname: deleted $deleted of $seen\n" if ($verbose); } } sub error($) { my ($err) = @_; # print STDERR blurb() . "$err"; # exit 1; die $err; } sub usage(;$) { my ($err) = @_; print STDERR "$progname: $err\n" if $err; print STDERR "usage: $progname user [--verbose] [--instagram]\n" . "\t\t [--caption txt]\n" . "\t\t [--alt txt] [--image filename]\n" . "\t\t [--alt txt] [--image filename] ...\n" . "\t or --delete '1 year ago' '1 month ago'\n"; print STDERR "usage: $progname user --generate-session\n"; exit 1; } sub main() { binmode (STDOUT, ':utf8'); binmode (STDERR, ':utf8'); my $user = undef; my @imgs = (); my $caption = ''; my $gen_p = 0; my $stats_p = 0; my $delete_from = undef; my $delete_to = undef; my $instagram_p = 0; my $alt = ''; while ($#ARGV >= 0) { $_ = shift @ARGV; if (m/^--?verbose$/) { $verbose++; } elsif (m/^-v+$/) { $verbose += length($_)-1; } elsif (m/^--?debug$/) { $debug_p++; } elsif (m/^--?caption$/) { $caption = shift @ARGV; } elsif (m/^--?gen(erate(-session)?)?$/) { $gen_p = 1; } elsif (m/^--?stats?$/) { $stats_p = 1; } elsif (m/^--?delete?$/) { $delete_from = shift (@ARGV); $delete_to = shift (@ARGV); } elsif (m/^--?insta(gram)?$/) { $instagram_p = 1; } elsif (m/^--?alt$/) { $alt = shift @ARGV; } elsif (m/^--?image$/) { push @imgs, [ shift @ARGV, $alt ]; $alt = ''; } elsif (m/^--?img$/) { push @imgs, [ shift @ARGV, $alt ]; $alt = ''; } elsif (m/^-./) { usage ("unknown: $_"); } elsif (!$user) { $user = $_; } else { usage("unknown: $_"); } } usage("no user") unless ($user); if ($gen_p) { generate_session ($user); exit (0); } elsif ($stats_p) { my $fb_auth = load_keys ($user); my $ig_id = instagram_id ($user, $fb_auth) if ($instagram_p); error ("no IG ID") if ($instagram_p && !$ig_id); my $ret = fb_load ("stats", $fb_auth, ($ig_id ? "$ig_id?fields=name,followers_count" : "$user?fields=name,followers_count,checkins")); error ("failed to load follower count") unless ($ret->{followers_count}); print STDOUT join ("\t", $ret->{name}, $ret->{followers_count}, ($ret->{checkins} || 0)) . "\n"; exit (0); } elsif ($delete_from) { facebook_delete ($user, $instagram_p, $delete_from, $delete_to); exit (0); } usage("no caption or images") unless ($caption || @imgs); $progname = "$progname: $user"; $Data::Dumper::Indent = 1; $Data::Dumper::Terse = 1; $Data::Dumper::Useqq = 1; $Data::Dumper::Quotekeys = 0; $Data::Dumper::Pair = "\t=> "; $Data::Dumper::Pad = " "; error ("--alt must come before the --image to which it applies") if ($alt); eval { if ($instagram_p) { # Post multiple images as a carousel. instagram_upload ($user, $caption, @imgs); # Multiple posts instead? # foreach my $img (@imgs) { # instagram_upload ($user, $caption, $img); # } } else { facebook_upload ($user, $caption, @imgs); } }; if ($@) { my $e = $@; $e .= "\n" unless ($e =~ m/\n$/s); print STDERR blurb() . "$e"; exit (1); } } main(); exit 0;