#!/opt/local/bin/perl -w # Copyright © 2016-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 Instagram. # Created: 9-Mar-2016. # # =========================================================================== # # HISTORY: # # Much of DNA Lounge's social media is driven by our calendar: posts go out # to all of our social media accounts when tickets go on sale, when events # begin, and so on. Instagram, however, does not have a public API that # lets you upload images or video. ("You Had One Job.") In fact, even # their web site does not let you upload things. You can only do it using # the phone app. # # So in 2016, I reverse-engineered the REST-based protocol that their iOS # app used to communicate with the server, and wrote this script which # impersonates a phone, and thus allows you to upload things to your own # account. # # This was difficult, and it's fragile, as Instagram makes minor tweaks to # that undocumented API constantly, and that tends to break this code. # # Some time later I discovered another API for doing the same thing, written # by "mgp25". That one was written in PHP, and was vastly more complicated # than mine, which is why I didn't just switch over and start using that # instead of my own. # # Some time around 2018, Instagram started using certificate pinning in # their iOS and Android apps, which makes it a lot harder to reverse-engineer # the protocol. There are ways around it, but I haven't figured that out yet. # So in the meantime, when my code broke or when I needed a new private key, # I got in the habit of checking out the "mgp25" changes to see how they had # solved it (since someone on their team clearly had a functional # Instagram-proxying development environment.) # # Then in November 2019, both my code and mgp25's code stopped being able # to upload anything. # # Then in February 2020, Instagram issued a DMCA takedown on mgp25. # Good times. # # # Facebook does provide yet another API that allows posting of images and # videos to Instagram (timeline only, not stories; and business accounts # only, not personal). It is documented here: # https://developers.facebook.com/docs/instagram-api/guides/content-publishing # but it has been in "private beta" for years and they won't let you use it. # # As far as I can tell, this API exists because Later.com had # reverse-engineered the Instagram API but then they either got acquired by # or bought their way into an official "Partnership" with Facebook. So it # seems that Facebook never intends for us to be able to use this API, they # only want re-sellers to be able to use it, for essentially money laundering # purposes. # # UPDATE: As of 2021 or so, that API is now public, and you can post to your # Instagram account using the Facebook Graph API. However: this only works # if the Instagram account is attached to a Facebook Business page (not a # user account) and it can only post to the feed, not stories. I've updated # my "facebook-upload.pl" script to be able to use that. So if this one # stops working, you can give that a try. # # There are a dozen or so businesses out there who purport to be able to # post to your Instagram account on your behalf, presumably using that API. # In February 2020 I contacted every one I could find and asked them, # # Does your service provide the ability to post to my Instagram Business # account via an API? That is: I want my server to contact your site and # say "post this image or video to my Instagram right now", without me # needing to use a GUI or web app to manually schedule it. # # Every one of them said "no". All of them require you to manually schedule # each post by hand in their custom, idiosyncratic online calendar. (Later, # Hootsuite, Onlypult, Crowdfireapp, Iconosquare, Bufferapp, Skedsocial, # Sproutsocial). # # # Anyway, I got it working again. It was hard. # # # =========================================================================== # # DISCUSSION: # # 2019: Why instagram-upload.pl still exists when mgp25 also exists: # https://jwz.org/b/yjFK # # 2020: Oh look, Instagram took down mgp25: # https://jwz.org/b/yjUS # # 2016: Discussion of reverse engineering tools: # https://jwz.org/b/yiXr # # 2020: Begging for help with mitmproxy and certificate pinning: # https://jwz.org/b/yjT7 # https://jwz.org/b/yjUP # https://jwz.org/b/yjUl # # 2016: Instagram Hates the Internet: A chart explaining why Instagram is # not a participant in the World Wide Web in any meaningful sense, # but is as much of a walled garden as Compuserve or AOL could have # ever aspired to be: # https://jwz.org/b/yiYE # # 2019: Instagram: How Not To Do Messaging: # https://jwz.org/b/yjKU # https://jwz.org/b/yjvf # # 2019: "Link In Bio" is a slow knife: # https://jwz.org/b/yjQa # https://jwz.org/b/yjTK # # # =========================================================================== # # INSTALLATION: # # Requires resize.pl (https://www.jwz.org/hacks/#resize) and ffmpeg to # adjust the image or video to the proper size and dimensions. # # First create a file called $HOME/.USER-instagram-pass which contains one # line, the password of USER, like so: # # PASS: xxxxx # # This file gets overwritten to cache other data, like session cookies so # that we don't have to log in from scratch every time (and possibly trigger # 2FA). # # Oh yeah, if you have 2FA turned on and this script gets logged out, you'll # need to run it from an interactive shell to be able to enter the 2FA code. # # # =========================================================================== # # USAGE: # # instagram-upload.pl USER --caption "TEXT" IMAGE-OR-VIDEO-FILE # # If the image is not in one of Instagram's accepted aspect ratios, it will # be padded to make it fit. # # With --story it will add the item to your story instead of doing a post. # # # To create an RSS feed of USER's friends feed: # # instagram-upload.pl USER --rss OUTPUT.RSS # # To also include recent posts with a given set of #hashtags, @mentions of # users, or at locations, include those with --tag. They can be separated # by spaces, or multiple arguments: # # --tag '#hash1 #hash2 @user1 @user2' # --tag '#hash1' --tag '#hash2' # # To include posts at a given location, specify that as the name of the # location as well as its latitude and longitude. Use underscores for # spaces. For example: # # --tags 'DNA_Lounge;37.771007;-122.412694' # # Mutual admiration society: make this user follow the same users as # user2, and like all the things that user2 posts or likes: # # --sync user2 # # If anyone we follow mentions us, hit like on it: # # --mentions # # To get a list of all direct messages and mentions: # # --messages # # How many followers do you have? # # --follower-count # # Delete posts between two dates: # # --delete FROM TO # # # =========================================================================== # # NOTE TO SELF: # # Remember that if things start going wonky, the **very first thing** # to try is to upgrade the secret key and user agent. Several times, # an incomprehensible failure has been solved by doing only that. # # =========================================================================== require 5; use diagnostics; use strict; # For resize.pl and ffmpeg. $ENV{PATH} = "/opt/local/bin:/var/www/jwz/hacks:$ENV{PATH}"; use open ":encoding(utf8)"; use POSIX; use IPC::Open3; use Date::Parse; use Time::ParseDate; use LWP::UserAgent; use IO::Socket; use IO::Socket::SSL; use HTTP::Cookies; use Digest::SHA; use JSON::Any; use HTML::Entities; use Time::HiRes qw(gettimeofday); use MIME::Base64; use Data::Dumper; my $progname = $0; $progname =~ s@.*/@@g; my ($version) = ('$Revision: 1.192 $' =~ m/\s(\d[.\d]+)\s/s); my $verbose = 0; my $debug_p = 0; my $base_url = 'https://www.instagram.com/'; my $base_api0_url = 'https://i.instagram.com/'; my $base_api_url = $base_api0_url . 'api/v1/'; # SHA256 HMAC signing key, extracted from the mobile Instagram app. # "Oh, what's this? Is it the unnamed account in the Bahamas where # the money was to be stashed? I think it is!" my $s00per_s3krit_app_key = # Android Instagram 6.21.2 -- no longer works: # '25eace5393646842f0d0c3fb2ac7d3cfa15c052436ee86b5406a8433f54d24a5'; # Android Instagram 7.10.0 -- no longer works: # 'c1c7d84501d2f0df05c378f5efb9120909ecfb39dff5494aa361ec0deadb509a'; # Android Instagram 7.13.1 -- no longer works; # '8b46309eb680f272cc770d214b7dbe5f0c5d26b6cb82b0b740257360b43618f0'; # Android Instagram 7.16.0 -- works, Mar 2016: # '6a5048da38cd138aacdcd6fb59fa8735f4f39a6380a8e7c10e13c075514ee027'; # Android Instagram 7.17.0 -- doesn't work: # 'b947b25e9bf1eb581d4a3f83301cb364b1fb42ce1886aba7f53a349770dc3a07', # I thought maybe this was iOS Instagram 7.18.0, but it's not: # '4d0c1bbd6846e97622631d869d293f53baeb7b75afe27a2d31fa5794ae2e705a'; # I tried: strings Instagram.ipa/Payload/Instagram.app/Instagram | # perl -ne 'print if (m/^[\da-f]{64}$/i);' # Android Instagram 8.0.0 -- works, June 2016: #'9b3b9e55988c954e51477da115c58ae82dcae7ac01c735b4443a3c5923cb593a'; # Android Instagram 8.5.1 -- works, Jul 2016: #'b5d839444818714bdab3e288e6da9b515f85b000b6e6b452552bfd399cb56cf0'; # Android Instagram 9.2.0 -- works, 3-Sep-2016. #'012a54f51c49aa8c5c322416ab1410909add32c966bbaa0fe3dc58ac43fd7ede'; # Android Instagram 27.0.0.7.97, 20-Apr-2018 #'109513c04303341a7daf27bb41b268e633b30dcc65a3fe14503f743176113869'; # Android Instagram 42.0.0.19.95, 15-Jun-2018 #'673581b0ddb792bf47da5f9ca816b613d7996f342723aa06993a3f0552311c7d'; # Android Instagram 85.0.0.21.100, 1-Aug-2019 #'937463b5272b5d60e9d20f0f8d7d192193dd95095a3ad43725d494300a5ea5fc'; # Android Instagram 107.0.0.27.121, 30-Oct-2019 'c36436a942ea1dbb40d7f2d7d45280a620d991ce8c62fb4ce600f0a048c32c11'; # I have not been able to update the app key since Oct 2019. # Best approach to finding a new one might be to do an all-Github code # search for: "const SIG_KEY_VERSION = '5'" # But as of Feb 2023, I'm only finding old version 4 keys on there. my $user_agent = sprintf('Instagram %s Android (%s/%s; %s; %s; %s; %s; %s; %s; %s; %s)', '107.0.0.27.121', # IG_VERSION -- must match app key '18', # Android version '4.3', # Android release '320dpi', # DPI '720x1280', # Resolution 'Xiaomi', # Manufacturer/brand 'HM 1SW', # Model 'armani', # Device 'qcom', # CPU 'en_US', # Locale '168361634' # Version code -- must match app key ); # This really shouldn't be hardcoded... # my %geo_by_user = ( 'dnalounge' => "37.771007;-122.412694;DNA Lounge", 'dnapizza' => "37.771007;-122.412694;DNA Pizza", 'codeword_sf' => "37.779835;-122.403655;Codeword SF", 'rot13sf' => "37.779835;-122.403655;ROT13" ); sub blurb() { return "$progname: " . strftime('%l:%M:%S %p: ', localtime); } # 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'; sub html_quote($) { my ($s) = @_; return undef unless defined ($s); $s =~ s/&/&/gs; $s =~ s//>/gs; return $s; } sub html_unquote($) { my ($s) = @_; 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 generate_guid() { return sprintf ('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', int (rand() * 0x10000), int (rand() * 0x10000), int (rand() * 0x10000), int (rand() * 0x400) + 0x4000, int (rand() * 0x4000) + 0x8000, int (rand() * 0x10000), int (rand() * 0x10000), int (rand() * 0x10000)); } sub load_keys($) { my ($user) = @_; error ("no \$HOME") unless defined($ENV{HOME}); my $pass_file = "$ENV{HOME}/.$user-instagram-pass"; my %auth; if (open (my $in, '<', $pass_file)) { print STDERR blurb() . "reading $pass_file\n" if ($verbose > 1); while (<$in>) { s/\n$//s; s/#.*$//gs; if (m/^\s*$/s) { } elsif (m/^ ( SESSION | USER | PASS | FULL_NAME | UID | GUID | UUID | CSRFTOKEN | RUR | DEVICE_ID | LAST_LOGIN | TOTP_SECRET ) : \s+ ([^\n]*) $/smix) { $auth{lc($1)} = $2 if $2; } else { error ("$pass_file: unparsable: $_"); } } close $in; } else { error ("$pass_file does not exist"); } $auth{user} = $user; foreach my $k ('guid', 'uuid', 'csrftoken', 'device_id') { $auth{$k} = generate_guid() unless $auth{$k}; } return \%auth; } # Writes the password and cookie back to the password file. sub save_keys($) { my ($auth) = @_; my $ua = $auth->{ua}; my $user = $auth->{user}; $auth->{session} = undef; # In case it got deleted? my $jar = $ua->cookie_jar(); $jar->scan (sub { my ($version, $key, $val, $path, $domain, $port, $path_spec, $secure, $maxage, $discard) = @_; $auth->{session} = $val if ($key eq 'sessionid'); }); print STDERR blurb() . "WARNING: $user: no sessionid cookie\n" unless $auth->{session}; # error ("$user: no sessionid cookie") unless $auth->{session}; my $pass_file = "$ENV{HOME}/.$user-instagram-pass"; my $who = `whoami`; chomp($who); open (my $in, '<', $pass_file) || error ("reading $pass_file: $! ($who)"); local $/ = undef; # read entire file my $obody = <$in>; my $body = ''; foreach my $k (sort keys %$auth) { next if ($k eq 'ua'); my $v = $auth->{$k}; $body .= uc($k) . ': ' . $v . "\n" if $v; } if ($body eq $obody) { print STDERR blurb() . "$pass_file unchanged\n" if ($verbose > 2); } else { open (my $out, '>', $pass_file) || error ("writing: $pass_file: $! ($who)"); print $out $body; close $out; print STDERR blurb() . "wrote $pass_file\n" if ($verbose > 1); } } sub generate_sig($) { my ($data) = @_; return Digest::SHA::hmac_sha256_hex ($data, $s00per_s3krit_app_key); } # With two args, does a GET. # With three args, does a signed POST. # Checks status and returns a hash of decoded JSON. # sub instagram_load($$;$$) { my ($ua, $url, $data, $no_error) = @_; my $res; $url = $base_api_url . $url unless ($url =~ m/^https?:/s); # my $retries = ($no_error ? 1 : ($debug_p ? 3 : 20)); my $retries = ($no_error ? 1 : 3); my $timeout = 10; my $limitedp = 0; my $ret = 'unknown'; my $delay = 4; my $start = time(); my $signed_data = undef; if ($data) { my $json = JSON::Any->new->objToJson ($data); my $sig = generate_sig ($json); $signed_data = "$sig.$json"; } $ua->timeout ($timeout); for (my $i = 0; $i < $retries; $i++) { if ($i > 0) { print STDERR (blurb() . "$url is status: $ret (" . ($res->content =~ m@^\s*content) . ") (retrying in " . sprintf("%d:%02d:%02d", $delay/60/60, ($delay/60)%60, $delay%60) . ")\n") if ($verbose > 1); sleep ($delay); $delay += 2 unless ($delay >= 20); } if ($data) { print STDERR blurb() . "post $url\n" if ($verbose > 1); print STDERR Dumper($data) . "\n\n$signed_data\n\n" if ($verbose > 2); $res = $ua->post ($url, # 'Accept-Language' => 'en-US;q=1', # 'X-IG-Connection-Type' => 'WiFi', # 'X-IG-Capabilities' => 'nw==', Content_Type => 'application/x-www-form-urlencoded', Content => { signed_body => $signed_data, ig_sig_key_version => 5 }); } else { print STDERR blurb() . "get $url\n" if ($verbose > 2); $res = $ua->get ($url); } $ret = ($res && $res->code) || 'null'; my $ct = $res->header ('Content-Type') || ''; my $jsonp = ($ct =~ m@^application/json@si); # Sometimes Instagram's 404 pages don't come with status 404. # Isn't that special. if (!$jsonp && $ret =~ m/Page Not Found|dialog-404/si) { $ret = '404'; } if ($ret ne '200') { $ret .= " " . ($res->message || 'unknown error'); if ($jsonp) { if ($res->content =~ m/"message":\s*"([^\"]+)"/si) { my $e = $1; if ($res->content =~ m/"error_title":\s*"([^\"]+)"/si) { $ret .= ": $1"; } if ($res->content =~ m/"error_body":\s*"([^\"]+)"/si) { $ret .= ": $1"; } if ($e eq 'checkpoint_required') { $e .= (' -- you must log in to the Instagram app and' . ' verify your phone number'); $retries = 0; } elsif ($e eq 'challenge_required') { $e .= (' -- you must log in to the Instagram app and' . ' verify 2-factor auth'); $retries = 1; } elsif ($e eq 'login_required') { $retries = 0; $e = ''; } elsif ($e eq 'User not found') { $retries = 0; } elsif ($e eq 'feedback_required') { $retries = 0; } $ret .= ": $e" if $e; } elsif ($res->content =~ m/"(two_factor_required)":\s*true/s) { $retries = 0; } elsif ($res->content =~ m/"logout_reason"/s) { $retries = 0; } elsif ($res->content =~ m/photo has been deleted/s) { $retries = 0; } elsif ($res->content =~ m/wrong code/s) { $retries = 0; } } } print STDERR blurb() . "status: $ret: " . ($jsonp ? $res->content : $ct) . "\n" if ($verbose > 2); # Sometimes, after a series of "202 Accepted: Transcode not finished yet" # we finally get an error of "Re-transcode needed" from inside "configure". # Re-doing the configure does not fix it, but re-uploading the exact same # video file from scratch does, sometimes. See instagram_upload_retry(); # # I've seen it fail 6 times in a row, and then I tried again ten minutes # later and it worked, with the exact same video file. # if ($ret =~ /transcode needed|transcode error/si) { $retries = 0; } elsif ($ret =~ m/check your input and try again/s) { # This happens on double-secret probation. $retries = 1 if ($retries > 1); } elsif ($ret =~ m/Please wait a few minutes/s) { #$retries = 0; $retries = 5 if ($retries > 5); $delay += 60 * 2 * ($i + 1); # We have to go around the loop again. Could also do this by # inserting a second call to sleep() here? And resetting $delay? $no_error = 0; $retries++; $limitedp++; print STDERR blurb() . "rate limited; sleeping for " . sprintf("%d:%02d:%02d", $delay/60/60, ($delay/60)%60, $delay%60) . "\n" if ($verbose); } if ($ret eq '200' || $no_error) { if ($jsonp) { $ret = undef; eval { $ret = JSON::Any->new->jsonToObj ($res->content); }; if (!$ret) { # Ignoring $no_error here... hmmm. my $s = $res->content; my $L = length($s); $s =~ s/^(.{100}).*(.{100})$/$1 ..... $2/s; $ret = "JSON unparsable: $L bytes: $s"; next; } elsif ($no_error) { if ($verbose && $limitedp) { my $n = time() - $start; print STDERR blurb() . "rate limit recovered after " . sprintf("%d:%02d:%02d", $n/60/60, ($n/60)%60, $n%60) . "\n"; } return $ret; # Return the JSON, or maybe an error string # return $res; # This is the LWP result hash } elsif (!$ret->{status} || $ret->{status} ne 'ok') { $ret = "bad JSON status: " . $res->content; next; } } if ($verbose && $limitedp) { my $n = time() - $start; print STDERR blurb() . "rate limit recovered after " . sprintf("%d:%02d:%02d", $n/60/60, ($n/60)%60, $n%60) . "\n"; } return $ret; } } my $secs = (time() - $start); error ("status $ret after $retries tries in " . sprintf("%d:%02d:%02d", $secs/60/60, ($secs/60)%60, $secs%60) . " -- $url"); } # Like instagram_load but caches the result forever. # Only the user and URL are used as the cache key, not the post data. # my %instagram_cache; sub instagram_load_cached($$$;$$) { my ($username, $ua, $url, $data, $no_error) = @_; my $key = "$username $url"; my $old = $instagram_cache{$key}; if (defined($old)) { print STDERR blurb() . "CACHE HIT: $key\n" if ($verbose > 3); return $old; } $old = instagram_load ($ua, $url, $data, $no_error); $instagram_cache{$key} = $old if defined($old); return $old; } # Sigh. # https://en.wikipedia.org/wiki/Java_hashCode()#The_java.lang.String_hash_functionjava # sub java_hashcode($) { my ($string) = @_; my $result = 0; for (my $i = 0, my $len = length($string); $i < $len; ++$i) { $result = ((-$result + ($result << 5) + ord (substr ($string, $i, 1))) & 0xFFFFFFFF); } if ($result > 0x7FFFFFFF) { $result -= 2**32; } elsif ($result < -0x80000000) { $result += 2**32; } return $result; # foo == 101574 } sub instagram_login($) { my ($user) = @_; my $oprogname = $progname; $progname .= ": login as $user"; my $auth = load_keys ($user); my $pass = $auth->{pass}; my $session = $auth->{session}; my $url = $base_url; my $ua = LWP::UserAgent->new; # Works but doesn't help: (ssl_opts => { SSL_version => 'TLSv13' }); $auth->{'ua'} = $ua; $ua->timeout (10); # Something has gone wrong if Instagram is this slow. if ($verbose > 4) { my $f = sub { shift->dump(maxlength => 1000); return }; $ua->add_handler("request_send", $f); $ua->add_handler("response_done", $f); } $ua->agent($user_agent); my $jar = HTTP::Cookies->new(); $ua->cookie_jar ($jar); my $guid = $auth->{guid}; my $uid = $auth->{uid}; # If the saved session cookie is too old, delete it and log in again. # Rumor has it that Instagram notices sessions that never re-log-in. # (Don't do this, because sometimes that triggers a 2FA request.) # # if ($session && $auth->{last_login} && # $auth->{last_login} < time() - (60*60*7)) { # $session = undef; # delete $auth->{session}; # } # If we have a session cookie saved in the password file, install that # into the user agent and try to use that to retrieve info about the # logged in user. If that succeeds, we're good (and other cookies will # have been set as a side effect). If not, go through the full login # process. if ($session) { my $vers = 0; my $key = 'sessionid'; my $path = '/'; # Set the cookie on both www.instagram.com and i.instagram.com my ($domain1) = ($base_url =~ m@^https?://([^/:]+)@si); my ($domain2) = ($base_api_url =~ m@^https?://([^/:]+)@si); my $port = 443; my $path_spec = 1; my $secure = 1; my $maxage = 60*60*24*365*10; my $discard = 0; $jar->set_cookie ($vers, $key, $session, $path, $domain1, $port, $path_spec, $secure, $maxage, $discard); $jar->set_cookie ($vers, $key, $session, $path, $domain2, $port, $path_spec, $secure, $maxage, $discard); $url = 'accounts/current_user/'; my $res = instagram_load ($ua, $url, undef, 1); if ($res && ref ($res) && $res->{status} && $res->{status} eq 'ok' && $res->{user} && $res->{user}->{full_name} && $res->{user}->{pk}) { $uid = $res->{user}->{pk}; $auth->{full_name} = $res->{user}->{full_name}; print STDERR blurb() . "re-using existing session\n" if ($verbose > 1); } else { print STDERR blurb() . "existing session failed\n" if ($verbose > 1); $uid = undef; # Force reload } } $uid = undef unless $session; if (!$uid) { # Either we didn't have a saved session cookie, or it failed. # Go to the login page. $jar = HTTP::Cookies->new(); # Empty the session $ua->cookie_jar ($jar); # Get the "csrftoken" and "mid" cookies. # $url = 'zr/token/result/?token_hash='; my $res = instagram_load ($ua, $url, undef, 1); # Ignored $res. } # Find the csrf and rur cookies. # my $csrf = undef; my $rur = undef; $jar->scan (sub { my ($version, $key, $val, $path, $domain, $port, $path_spec, $secure, $maxage, $discard) = @_; $csrf = $val if ($key eq 'csrftoken'); $rur = $val if ($key eq 'rur'); }); print STDERR blurb() . "WARNING: no csrf cookie in $url\n" unless $csrf; #print STDERR blurb() . "WARNING: no rur cookie in $url\n" unless $rur; # error ("no csrf cookie in $url") unless $csrf; # error ("no rur cookie in $url") unless $rur; $auth->{csrftoken} = $csrf; $auth->{rur} = $rur if $rur; if (! $uid) { # Log in with user and pass. # $url = 'accounts/login/'; my $res = instagram_load ($ua, $url, # ig_sig_key_version 5 { username => $user, password => $pass, # _uuid => $guid, device_id => $auth->{device_id}, guid => $guid, adid => $guid, _csrftoken => $csrf, login_attempt_count => '0', }, 1); if ($res && !(ref $res)) { # Sometimes it's a string, "503 Service Unavailable" $res = { error_title => $res }; } # Oh, FFS. Sometimes the booleans are "true" or "false" and sometimes # they are JSON::PP::Boolean false bullshit! $res->{'two_factor_required'} = 0 if (($res->{'two_factor_required'} || '') =~ m/^false$/si); if (($res->{error_type} || '') eq 'checkpoint_challenge_required') { save_keys ($auth); #### No idea if we are expected to load this my $challenge = ($res->{challenge} ? $res->{challenge}->{url} : undef); print STDERR blurb() . "#### challenge " . Dumper($res); if ($challenge) { print STDERR blurb() . "#### load challenge $challenge\n";#### if ($verbose); my $res2 = $ua->get ($challenge); print STDERR blurb() . "#### challenge loaded " . Dumper($res2); # { # my $url = 'zr/token/result/?token_hash='; # # $no_error must be false to retry if rate limiting during login. # my $res = instagram_load ($ua, $url, undef, 0); # } } if (0 && -t STDIN) { #### SHIT FUCK NO WORKY print STDERR blurb() . "you must log in to the Instagram app and" . " verify 2-factor auth.\n\nHit RET when you have done so: "; my $o = select (STDOUT); $| = 1; select ($o); my $ret = ; # $url = 'accounts/current_user/'; # $url = 'zr/token/result/?token_hash='; # $res = instagram_load ($ua, $url, undef, 1); $url = 'accounts/login/'; $res = instagram_load ($ua, $url, # ig_sig_key_version 5 { username => $user, password => $pass, _uuid => $guid, device_id => $auth->{device_id}, _csrftoken => $csrf, login_attempt_count => '0', }, 1); } else { error ('you must log in to the Instagram app and' . ' verify 2-factor auth'); } } elsif ($res->{'two_factor_required'}) { my $tfi = $res->{'two_factor_info'} || error ("no TFI: " . Dumper($res)); my $id = $tfi->{'two_factor_identifier'} || error ("no 2FA ID: " . Dumper($tfi)); # Oh, FFS. Sometimes the booleans are "true" or "false" and sometimes # they are JSON::PP::Boolean false bullshit! foreach my $k ('sms_two_factor_on', 'totp_two_factor_on') { $tfi->{$k} = 0 if (($tfi->{$k} || '') =~ m/^false$/si); } my $code = ''; if ($tfi->{'sms_two_factor_on'}) { my $sms = $tfi->{'obfuscated_phone_number'} || '????'; error ("SMS 2FA required, not a typewriter") unless (-t STDIN); print STDERR blurb() . "SMS 2FA required ($sms)\n"; print STDOUT "\n\n" . blurb() . "$user: Enter 2FA SMS code," . " sent to number ending in $sms: "; my $o = select (STDOUT); $| = 1; select ($o); $code = ; } elsif ($tfi->{'totp_two_factor_on'}) { # Use "oauthtool" from "oath-toolkit" to respond to TOTP 2FA if # we have the TOTP seed saved in the password file. # my $secret = $auth->{'totp_secret'}; if ($secret) { sleep (1 + rand(2)); # Don't respond too fast my ($inf, $outf, $errf); $errf = Symbol::gensym; # Requires oathtool 2.6.5 or newer to read secret from stdin, # because it's fucking amateur hour up in here. my @cmd = ('oathtool', '--totp', '-b', '-'); my $pid = eval { open3 ($inf, $outf, $errf, @cmd) }; error ("unable to exec $cmd[0]: $!") unless $pid; print $inf "$secret\n"; close ($inf); my $errs = ''; while (<$errf>) { $errs .= $_; } $code = <$outf>; waitpid ($pid, 0); close ($outf); close ($errf); error ("$cmd[0]: $errs") if ($errs); } else { error ("TOTP 2FA required, not a typewriter") unless (-t STDIN); print STDERR blurb() . "TOTP 2FA required\n"; print STDOUT "\n\n" . blurb() . "$user: Enter 2FA TOTP code: "; my $o = select (STDOUT); $| = 1; select ($o); $code = ; } } else { error ("2FA unknown kind: " . Dumper($res)); } $code =~ s/\s+//gs; error ("no 2FA code entered") unless $code; $res = instagram_load ($ua, 'accounts/two_factor_login/', { 'verification_code' => $code, 'two_factor_identifier' => $id, '_csrftoken' => $csrf, 'username' => $user, 'password' => $pass, 'device_id' => $auth->{device_id}, }); error ("2FA failed") unless $res; save_keys ($auth); print STDERR blurb() . "2FA response success\n"; } $res = $res->{logged_in_user} if $res; $uid = $res->{pk}; error ("no uid (pk) in $url") unless $uid; $auth->{uid} = $uid; $auth->{full_name} = $res->{full_name}; error ("no full_name in $url") unless $uid; $auth->{last_login} = time(); } save_keys ($auth); $progname = $oprogname; return $auth; } 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'); } # Like system() but respects error codes. # sub safe_system(@) { my @cmd = @_; print STDERR blurb() . "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; } # Returns width, height, duration, audio_p, sar of the video file. # sub video_size($$) { my ($name, $file) = @_; my @cmd = ('ffmpeg', '-hide_banner', '-loglevel', 'info', # so it prints duration, etc. '-i', $file); print STDERR blurb() . "size: exec: " . join(" ", @cmd) . "\n" if ($verbose > 2); my $result = ''; my ($inf, $outf, $errf); $errf = Symbol::gensym; my $pid = eval { open3 ($inf, $outf, $errf, @cmd) }; error ("unable to exec $cmd[0]: $!") unless $pid; close ($inf); close ($outf); my $errs = ''; while (<$errf>) { $errs .= $_; } waitpid ($pid, 0); close ($errf); print STDERR $errs if ($verbose > 3); my ($h, $m, $s, $ss) = ($errs =~ m/Duration: (\d\d):(\d\d):(\d\d).(\d\d),/s); error ("$name: no duration: $errs") unless defined($ss); my $dur = $h*60*60 + $m*60 + $s + $ss/100; # $dur = sprintf("%.1f", $dur) + 0; # one significant digit my ($line) = ($errs =~ m/^\s*Stream [^\n]* Video: ([^\n]+)/m); $line = '' unless $line; my ($fw, $fh) = ($line =~ m/ (\d\d\d?\d?)x(\d\d\d?\d?)[, ]/s); # SAR indicates non-square pixels in storage (anamorphic video) my ($sar_w, $sar_h) = ($line =~ m/\bSAR (\d+):(\d+)\b/s); my $sar = $sar_h ? $sar_w / $sar_h : 1; if ($sar && $sar != 1) { $fw = int(($fw * $sar) + 0.5); $fw++ if ($fw & 1); # h264 requires even-numbered dimensions. } ($fw, $fh) = ($fh, $fw) if ($errs =~ m/ rotate *: *-?90\b/s); my $audio_p = ($errs =~ m/Stream .* Audio: /m); return ($fw, $fh, $dur, $audio_p, $sar); } # Returns: # name, content-type, # filename (contains image or video data) # thumbnail-filename (contains JPEG data if it's a video) # sub load_photo_data($;$$$) { my ($file, $story_p, $crop_p, $caption) = @_; my ($ct, $name, $thumb); # If we have an output file, $TMPDIR can mean "Invalid cross-device link" # No, in this case we are never replacing another file with this one, # we just delete it when we're done. my $tmp = $ENV{TMPDIR} || '/tmp'; my $dir = $tmp; #my ($dir) = ($file =~ m@^(.*)/[^/]*$@s); #$dir = '.' unless $dir; #$dir = $tmp if ($dir =~ m/^https?:/s); my $base = sprintf("%s/.instup-%08x", $dir, rand(0xFFFFFFFF)); $base =~ s@//+@/@gs; $base =~ s@^\./@@gs; if ($file =~ m@\.(m3u8)?$@si) { $ct = ext_to_ct ($file); } elsif ($file =~ m@^https?://@si) { my $url = $file; my $ua = LWP::UserAgent->new; $ua->agent("$progname/$version"); my $res; $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); unlink $file; 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 ("failed to write $file: cwd: " . `pwd`) unless (-f $file); error ("$url failed: $ret") unless ($ret eq '200'); $ct = $res->header ('Content-Type') || 'application/octet-stream'; $ct =~ s/[;\s].*$//s; } else { error ("$file does not exist") unless (-f $file); $ct = ext_to_ct ($file); } if (! $name) { $name = $file; $name =~ s@^.*/@@s; } $name =~ s/:(large|medium|small)$//s; # Twitter $name =~ s@\.[^./]+$@@s; # extension $name =~ s/^\s+|\s+$//s; $name =~ s/\s+\[[-_a-z\d]{11,}\]$//gsi; # Youtube ID my @cmd = ('resize.pl', '--preset', ($story_p ? 'instagram-story' : 'instagram'), '-q'); # If this is a story, then burn the caption text directly into the # image or video, because Instagram is IN SANE. # if ($story_p && $caption) { utf8::decode($caption); # Parse multi-byte UTF-8 into wide chars. $caption =~ s/\\n/\n/gs; # Double-quoted. # $caption =~ s@\bhttps:[^\s]+@@gs; # Lose all URLs. # resize.pl limits the text to the bottom 1/3rd of the window, with # a 3 line bottom margin, so we don't really need to word-truncate # things; it will just be clipped. # $caption =~ s@(\s+\#[^\s]+)+\s*$@@gs; # Lose all trailing hashtags. # $caption =~ s@[^a-z\d()]+$@@gsi; # Lose all trailing punctuation. # $caption =~ s/\n+/ /gs; # Lose newlines? $caption =~ s/^\s+|\s+$//gs; $caption =~ s/^[ \t]+|[ \t]+$//gm; # $caption =~ s/^(.{100}).*/$1.../gs; # Truncate if still super long. push @cmd, ('--caption', $caption) if ($caption); } push @cmd, ($crop_p ? '--crop' : '--pad'); push @cmd, ('-' . ('v' x ($verbose-2))) if ($verbose > 2); push @cmd, $file; my $file2 = "$base-$name-2"; my $ext = ($ct eq 'image/gif' ? 'mp4' : # Convert anim-GIF to MP4. $ct =~ m@^video/@si ? 'mp4' : # Constrain MP4 video. $ct =~ m@^application/(x-)?mpegurl@si ? 'mp4' : # M3U video 'jpg'); # Constrain JPEG. $file2 .= ".$ext"; rm_atexit ($file2); push @cmd, ('--out', $file2); safe_system (@cmd); $file2 = $file unless (-f $file2); # Might have needed no changes my ($w, $h, $dur, $audio_p); if ($ext eq 'mp4') { # Get thumbnail. $ct = 'video/mp4'; $thumb = "$base-$name-3.jpg"; rm_atexit ($thumb); pop @cmd; push @cmd, $thumb; safe_system (@cmd); error ("$name: no thumbnail file: $thumb") unless (-f $thumb); ($w, $h, $dur, $audio_p) = video_size ($file, $file2); } my @ret = ($name, $ct, $file2, $thumb, $w, $h, $dur, $audio_p); if ($debug_p) { print STDERR blurb() . "not uploading: \n" . Dumper (\@ret); exit (1); } return @ret; } # Returns the distance between two lat/long coords in meters. # sub lat_long_distance($$$$) { my ($lat1, $lon1, $lat2, $lon2) = @_; sub deg2rad($) { my ($d) = @_; my $pi = 3.141592653589793; return $d * $pi / 180; } my $dlat = deg2rad($lat2-$lat1); my $dlon = deg2rad($lon2-$lon1); my $a = (sin($dlat/2) * sin($dlat/2) + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dlon/2) * sin($dlon/2)); my $c = 2 * atan2(sqrt($a), sqrt(1-$a)); my $R = 6371; # radius of Earth in KM my $d = $R * $c * 1000; # distance in M return $d; } sub instagram_place_id($$$$$) { my ($user, $ua, $lat, $lon, $name) = @_; # Search for a location closest to the lat/lon. If name is provided, # prefer a place that has that full name. The name can also be a # numeric Facebook place ID. # Why does Instagram sometimes stop being able to resolve places? my $start = time(); my $retries = $debug_p ? 1 : 10; # Fuck this shit entirely. There are so many redundant "places". # Prefer the ones that have our FB Page IDs in them. # No, just always hardcode it. # my ($target_fb_id, $magic_pk); if (($name || '') eq 'DNA Lounge' || $user eq 'dnalounge') { ($target_fb_id, $magic_pk) = ('12161711085', '50472'); } elsif (($name || '') eq 'DNA Pizza' || $user eq 'dnapizza') { ($target_fb_id, $magic_pk) = ('192927147394687', '213844912'); } if ($magic_pk) { my %ret = ( name => $name, lat => $lat, lng => $lon, facebook_places_id => $target_fb_id, pk => $magic_pk ); return \%ret; } #print STDERR "#### WARNING no place name: $lat $lon\n" unless ($name); print STDERR "#### WARNING no fbid: $lat $lon $name\n" if ($name && !$target_fb_id); for (my $i = 0; $i < $retries; $i++) { my $now = time(); # Sometimes searching by name helps, sometimes it does not. # Sometimes DNA Pizza doesn't show up unless we mention it. # Sometimes mentioning a name makes it fail. So do both. # Sometimes, blank query gets a 400 Bad Request response. my $url1 = ('fbsearch/places/' . '?lat=' . $lat . '&lng=' . $lon . '&timezone_offset=7' . '&query=' . ''); my $url2 = $url1 . ($name || ''); my $ret1 = instagram_load_cached ('ANY', $ua, $url1, undef, 1); my $ret2 = instagram_load_cached ('ANY', $ua, $url2, undef, 1) unless ($url1 eq $url2); $ret1 = ($ret1 && ref $ret1 eq 'HASH' && ($ret1->{venues} || $ret1->{items})) || $ret1; $ret2 = ($ret2 && ref $ret2 eq 'HASH' && ($ret2->{venues} || $ret2->{items})) || $ret2; # Sometimes $ret1 or $ret2 are error message strings or HASHes. $ret1 = undef if (ref($ret1) ne 'ARRAY'); $ret2 = undef if (ref($ret2) ne 'ARRAY'); if ($ret1) { foreach my $v (@$ret1) { $v = $v->{location} || $v; } } if ($ret2) { foreach my $v (@$ret2) { $v = $v->{location} || $v; } } my $closest = undef; my $result = undef; foreach my $v (($ret1 ? @$ret1 : ()), ($ret2 ? @$ret2 : ())) { my $dist = ($v->{lat} ? lat_long_distance ($lat, $lon, $v->{lat}, $v->{lng}) : 9999999); $v->{distance} = $dist; $dist = 9999 # These are fucking not places! if ($v->{name} && $v->{name} =~ m/\b(hubba|bootie)\b/si); $v->{dist} = $dist; $v->{fb_id_match} = 1 if ($target_fb_id && $v->{facebook_places_id} && $v->{facebook_places_id} eq $target_fb_id); $v->{exact} = 1 if ($name && $dist < 1000 && # Fuck you, DNA Australia! (($v->{name} && lc($v->{name}) eq lc($name)) || ($v->{facebook_places_id} && $v->{facebook_places_id} eq $name))); $closest = $v if ($dist < 50 && # only guess on close ones (!$closest || $dist < $closest->{distance})); $result = $v if ($v->{exact} && (!$result || !$result->{fb_id_match})); $result = $v if $v->{fb_id_match}; print STDERR blurb() . sprintf(" %.6f %.6f (%.0f M) %s %s%s%s\n", $v->{lat} || -1, $v->{lng} || -1, $dist, $v->{name}, $v->{facebook_places_id}, ($v->{fb_id_match} ? ' !!!' : $v->{exact} ? ' ***' : ''), ($v == $closest ? ' CL' : '') ) if ($verbose > 3); } if (!$name && !$result) { # If we aren't searching for a name, use the closest. $result = $closest; } elsif (!$result && $i >= $retries - 1) { # If we didn't find one with our name, use the closest instead. # But try a few times first. $result = $closest; } if ($verbose > 1) { if ($result) { print STDERR blurb() . sprintf("picked: %.6f %.6f (%.0f M) %s %s%s%s\n", $result->{lat} || -1, $result->{lng} || -1, $result->{dist} || -1, $result->{name}, $result->{facebook_places_id}, ($result->{fb_id_match} ? ' (page)' : $result->{exact} ? ' (exact)' : ' (APPROXIMATE)'), ($result == $closest ? ' CL' : '') ); } else { print STDERR blurb() . "no location in range for \"" . ($name || "-") . "\" $lat;$lon\n"; } } # You know what, fuck it, approximate results are always bullshit. $result = undef if ($name && !$result->{exact}); return $result if $result; sleep (2 + $i) # Try again unless $debug_p; } print STDERR blurb() . "no such place: " . ($name || "$lat;$lon") . " after $retries tries in " . (time() - $start) . " secs\n" if ($verbose); return undef; } # Uploads in chunks, using the rupload_igphoto/ or rupload_igvideo/ endpoints. # sub instagram_upload_resumable($$$$$;$); sub instagram_upload_resumable($$$$$;$) { my ($user, $file, $pdata, $auth, $story_p, $parent_upload_id) = @_; # $parent_upload_id=undef if ($story_p); my $ua = $auth->{ua}; my ($photo_name, $content_type, $image_file, $thumb_file, $w, $h, $dur, $audio_p) = @$pdata; my $video_p = ($content_type =~ m@^video/@si); my $upload_id = time(); my $fn2 = $image_file; $fn2 =~ s@^.*/@@s; my $entity_name = $upload_id . '_0_' . java_hashcode($fn2); my $url = ($base_api0_url . ($video_p ? 'rupload_igvideo/' : 'rupload_igphoto/') . $entity_name); print STDERR blurb() . "uploading $photo_name...\n" if ($verbose == 1); my $params = ($video_p ? { media_type => 2, upload_id => $upload_id, upload_media_width => $w, upload_media_height => $h, upload_media_duration_ms => $dur, for_album => ($story_p ? 1 : 0), } : { media_type => ($parent_upload_id ? 2 : 1), upload_id => ($parent_upload_id || $upload_id), }); print STDERR blurb() . "params: " . Dumper($params) if ($verbose > 2); $params = JSON::Any->new->objToJson($params); my $L = (stat($image_file))[7]; open (my $in, '<:raw', $image_file) || error ("$image_file: $!"); my $uuid = generate_guid(); my $retries = 100; my $chunk_count = 0; while (1) { # Ask the server what byte offset in the file from which to begin # sending data. # print STDERR blurb() . "GET $url\n" if ($verbose > 1); my $res = $ua->get ($url, 'X_FB_VIDEO_WATERFALL_ID' => $uuid, 'X-Instagram-Rupload-Params' => $params, ); my $ret = ($res && $res->code) || 'null'; error ("offset request failed: $ret: " . $res->content) unless ($ret eq '200' || $ret eq '201'); eval { $ret = JSON::Any->new->jsonToObj ($res->content); }; error ("offset JSON unparsable: " . $res->content) unless $ret; my $upload_start = $ret->{offset}; error ("offset request failed: $ret: " . $res->content) unless (defined ($upload_start)); # Seek in the file and read the next chunk into memory. # Try to avoid loading the whole file. # seek ($in, $upload_start, 0) || error ("$image_file: seek: $!"); my $remaining = $L - $upload_start; my $chunk_size = 1024 * 1024; $chunk_size = $remaining if ($chunk_size > $remaining); my $buf = ''; my $n = sysread ($in, $buf, $chunk_size); error ("$image_file: sysread $chunk_size => $n") unless ($n > 0); # Send the next chunk to the server. # my $pct = int (100 * $upload_start / $L); print STDERR blurb() . "upload $chunk_count: $pct%: $upload_start $url\n" if ($verbose > 1); print STDERR blurb() . "POST $url\n" if ($verbose > 1); $res = $ua->post ($url, 'X_FB_VIDEO_WATERFALL_ID' => $uuid, 'X-Instagram-Rupload-Params' => $params, 'X-Entity-Type' => $content_type, 'X-Entity-Name' => $entity_name, 'X-Entity-Length' => $L, Offset => $upload_start, Content_Length => $n, Content => $buf, ); $ret = ($res && $res->code) || 'null'; last if ($ret eq '200' || $ret eq '201'); error ("chunk upload failed: $ret: $upload_start-$n/$L: " . $res->content) unless ($ret eq '206'); # "Partial request error ("too many retries ($retries) for $image_file") if ($chunk_count++ >= $retries); } close ($in); if ($video_p) { # Recurse for this video's thumbnail. my @thumb = ($photo_name, 'image/jpeg', $thumb_file); my $id2 = instagram_upload_resumable ($user, $file, \@thumb, $auth, $story_p, $upload_id); } return $upload_id; } sub instagram_upload($$$$$$$$) { my ($user, $caption, $place, $file, $story_p, $crop_p, $countdown, $link) = @_; my @pdata = load_photo_data ($file, $story_p, $crop_p, $caption); my $auth = instagram_login ($user); my $ua = $auth->{ua}; my ($photo_name, $content_type) = @pdata; my $uname = $auth->{full_name}; print STDERR blurb() . "logged in as $user\n" if ($verbose > 1); my $place_name = undef; if (! $place) { $place = $geo_by_user{$user}; $place_name = $uname; # Look for exact match of place = user. } if ($place) { my ($lat, $lon, $fbid) = split(/\s*[,;]\s*/, $place); $place_name = $fbid if $fbid; $place = instagram_place_id ($user, $ua, $lat, $lon, $place_name); } # if ($debug_p) { # print STDERR blurb() . "not uploading: \n" . Dumper (\@pdata); # return; # } my $id = instagram_upload_resumable ($user, $file, \@pdata, $auth, $story_p); my $url = instagram_configure ($user, $caption, $place, $id, $story_p, $countdown, $link, \@pdata, $auth); # Finally, print the URL on stdout. print STDOUT "$url\n" if ($verbose); } sub instagram_upload_retry($$$$$$$$$); sub instagram_upload_retry($$$$$$$$$) { my ($user, $caption, $place, $file, $fallback_image, $story_p, $crop_p, $countdown, $link) = @_; my $retries = 4; my $err = 'unknown'; my $start = time(); for (my $i = 0; $i < $retries; $i++) { eval { instagram_upload ($user, $caption, $place, $file, $story_p, $crop_p, $countdown, $link); }; $err = $@; if (!$err) { return; # no error } elsif ($err =~ /transcode needed|transcode error/si) { # Sometimes, after a series of "202 Accepted: Transcode not finished # yet" we finally get an error of "Re-transcode needed" from inside # "configure". Re-doing the configure does not fix it, but # re-uploading the exact same video file from scratch does. # # Go around the loop again. # sleep (10); print STDERR blurb() . "$err; retrying...\n";## if ($verbose); } elsif ($fallback_image) { # If video has failed, but we have a fallback image, just post an # image instead. This is in case video uploads are broken but image # uploads are not, which has happened before... print STDERR blurb() . "status $err after $retries tries in " . (time() - $start) . " secs -- $file\n"; print STDERR blurb() . "retrying with fallback image $fallback_image.\n"; return instagram_upload_retry ($user, $caption, $place, $fallback_image, undef, $story_p, $crop_p, $countdown, $link); } else { die $err; # This error is real. } $i++; } error ("status $err after $retries tries in " . (time() - $start) . " secs -- $file"); } # Proof-of-life hash for user typing in comments. # sub user_breadcrumb($) { my ($size) = @_; my $key = 'iN4$aGr0m'; my $date = int (gettimeofday * 1000); # milliseconds # typing time my $term = int (((2 + rand()) * 1000) + ($size * (15 + ((rand() * 5) * 100)))); # android EditText change event occur count my $text_change_event_count = int ($size / (2 + rand())); if ($text_change_event_count == 0) { $text_change_event_count = 1; } # generate typing data my $data = "$size $term $text_change_event_count $date"; my $ret = (Digest::SHA::hmac_sha256_base64 ($data, $key) . "\n" . encode_base64 ($data) . "\n"); return $ret; } # After uploading, the image must be "configured" or it doesn't really # exist. I presume they get GC'd after a while? Also this is the # only way to set the caption. You can't send it along with the image. # sub instagram_configure($$$$$$$$$) { my ($user, $caption, $place, $upload_id, $story_p, $countdown, $link, $pdata, $auth) = @_; my $ua = $auth->{ua}; my ($photo_name, $content_type, $image_file, $thumb_file, $w, $h, $dur, $audio_p) = @$pdata; print STDERR blurb() . "uploaded, configuring...\n" if ($verbose > 1); # Last URL in text. ($link) = (($caption || '') =~ m@^.*\b(https?://[^\s()<>]+)@s) unless $link; $caption = '' unless defined($caption); $caption =~ s/[ \t]+/ /gs; $caption =~ s/^ +| +$//gm; $caption =~ s/^\s+|\s+$//gs; utf8::decode($caption); # Parse multi-byte UTF-8 into wide chars. my $video_p = ($content_type =~ m@^video/@s); my $data; my $uuid = generate_guid(); $data = { caption => "$caption", upload_id => "$upload_id", _csrftoken => $auth->{csrftoken}, _uuid => $uuid, _uid => $auth->{uid}, source_type => 0, scene_type => 1, scene_capture_type => "standard", camera_position => "unknown", geotag_enabled => JSON::Any->false, }; if ($video_p) { $data->{source_type} = 'library'; #$data->{video_result} = 'deprecated'; $data->{video_result} = ''; $data->{length} = $dur; # As of Oct 2016, audio_muted must be true if the MP4 has no audio track. $data->{audio_muted} = ($audio_p ? JSON::Any->false : JSON::Any->true); if ($story_p) { $data->{source_type} = 4; $data->{'poster_frame_index'} = 0; $data->{'filter_type'} = 0; $data->{'extra'} = { source_width => $w, source_height => $h }; $data->{'configure_mode'} = 1; # "REEL_SHARE" $data->{'client_shared_at'} = "" . time(); $data->{'client_timestamp'} = "" . (time() - 1); $data->{'story_media_creation_date'} = "" . (time() - 1); } } if ($countdown) { my ($time, $text) = @$countdown; my $cd = { text => uc ($text), text_color => '#FFFFFF', # text fg start_background_color => '#002200', # top left gradient end_background_color => '#009900', # bottom right gradient digit_color => '#000000', # digit fg digit_card_color => '#FFFFFF', # digit bg end_ts => $time, following_enabled => JSON::Any->true, is_sticker => JSON::Any->true, z => 0, # X,Y set the *center* of the countdown box. # W,H are size of the box as ratio of underlying image size. x => 0.68, y => 0.76, width => 0.7, height => 0.3, rotation => 0.01, # 0.5 = 180 deg }; # A single-element array, double-JSON encoded, because of course it is. $data->{story_countdowns} = JSON::Any->new->objToJson( [ $cd ] ); $data->{story_sticker_ids} = 'countdown_sticker_time'; } if ($place) { $data->{geotag_enabled} = JSON::Any->true; $data->{posting_latitude} = 0 + $place->{lat}; $data->{posting_longitude} = 0 + $place->{lng}; $data->{media_latitude} = 0 + $place->{lat}; $data->{media_longitude} = 0 + $place->{lng}; $data->{media_altitude} = 0.0; $data->{foursquare_request_id} = $place->{foursquare_v2_id} || ''; $data->{location} = { lat => 0 + $place->{lat}, lng => 0 + $place->{lng}, name => $place->{name}, external_source => $place->{external_source}, facebook_places_id => $place->{facebook_places_id}, address => $place->{address}, }; # This field is double-encoded! $data->{location} = JSON::Any->new->objToJson($data->{location}); } if ($story_p) { $data->{'configure_mode'} = '1'; # This seems not to work, but lore indicates that you can only post # links if you are "Verified", or if you are a business account with # more than 10k followers. # # I don't see a way to add a link to a story that I post manually in # the app, either: the "link" icon isn't there for me. # # Update, Feb 2022: The "dnalounge" account now has more than 10k # followers, but adding 'story_cta' to the story data does nothing. # Swiping up on a story now brings up a menu that lets you react # with emoji instead, so maybe the swipe-up thing is just fully gone? # # Apparently the new way is with "Link Stickers", which can be added # to "stories" but not to "posts". These used to be restricted to # verified accounts, but now anyone can use them, alegedly. # # $data->{story_cta} = # JSON::Any->new->objToJson ( [{links => [{ webUri => $link }]}] ) # if ($link); # This also does not work: # # if ($link) { # $data->{story_bloks_tappables} = # JSON::Any->new->objToJson ( # [ { # is_hidden => 0, # is_sticker => 1, # is_fb_sticker => 0, # is_pinned => 0, # # width => "0.430481401221741", # height => "0.04213585134443", # x => "0.629744014572996", # y => "0.499769145501789", # z => 0, # rotation => "0.027692363958605", # # bloks_tappable_sticker => { # bloks_app => # "com.instagram.stories.bloks_tappable_stickers.stories_fallback.action", # id => "stories_fallback_sticker_id", # bloks_parameters => {}, # nux_tooltip_text => "Tap to learn more." # }, # } # ]); # } } my $url = (($story_p ? 'media/configure_to_story/' : 'media/configure/') . ($video_p ? '?video=1' : '')); my $res = instagram_load ($ua, $url, $data); my $err = ($res ? $res->{error_title} || '' : ''); $res = $res->{media} if $res; my $shortcode = $res->{code} if $res; my $media_id = $res->{id} if $res; error ("no shortcode: $err") unless $shortcode; error ("no media_id: $err") unless $media_id; # Check to see if it's actually there -- sometimes it appears, then is # immediately deleted. Maybe if we have exceeded a rate limit? # # Update: as of June 2019, the URLs of stories are not loadable, so we # can't test that. # $url = "${base_url}p/$shortcode/"; if (! $story_p) { sleep (5); # This will retry 20 times over about 6.5 minutes until it gets a 200. instagram_load ($ua, $url); if ($caption) { $res = instagram_load ($ua, "media/$media_id/info/"); if (!$res->{items} || !$res->{items}[0] || !$res->{items}[0]->{caption} || !$res->{items}[0]->{caption}->{text}) { my $cap2 = $caption; $cap2 =~ s/\n.*/.../s; $cap2 =~ s/^(.{40}).+/$1.../s; print STDERR blurb() . "WARNING: caption lost: $url\n" . "\t\"$cap2\"\n" . "\t\ttrying comment...\n\n"; # Try to post it as a comment $res = instagram_load ($ua, "media/$media_id/comment/", { _uuid => $auth->{guid}, _uid => $auth->{uid}, device_id => $auth->{device_id}, _csrftoken => $auth->{csrftoken}, container_module => 'comments_v2', radio_type => 'wifi-none', idempotence_token => generate_guid(), 'user_breadcrumb' => user_breadcrumb (length ($caption)), comment_text => $caption, }); print STDERR "#### COMMENT\n" . Dumper($res); } } } # We are unable to disable comments on posts when going through # the Facebook API, so all this does is disable comments on stories, # which doesn't add much, I guess. if ($user eq 'jwz') { instagram_disable_comments ($user, $media_id, $story_p, $auth); } return $url; } sub instagram_disable_comments($$$$) { my ($user, $id, $story_p, $auth) = @_; my $ua = $auth->{ua}; print STDERR blurb() . "disabling comments on $id\n" if ($verbose > 1); my $url = "media/$id/disable_comments/"; my $data = { _csrftoken => $auth->{csrftoken}, _uuid => $auth->{guid}, }; my $res = instagram_load ($ua, $url, $data, 1); # no_error my $err = ($res ? $res->{error_title} || '' : ''); print STDERR blurb() . "disabling comments: $id: $err\n" if ($err); } # Returns a list of the entries in the output RSS file. # This is necessary to make sure things don't expire off the end of the # RSS feed before we've read them. # sub load_rss($) { my ($file) = @_; my $old = ''; if (open (my $in, '<:utf8', $file)) { local $/ = undef; # read entire file $old = <$in>; close $in; } $old =~ s/().*?$@$1@si if (@items); my @items2; my %dups; foreach my $item (@items) { my ($url) = ($item =~ m@]*>(.*?)<@si); if ($dups{$url}) { print STDERR blurb() . "old dup! $url\n"; } else { $dups{$url} = $item; push @items2, $item; } } return @items2; } # Returns RSS and permalink. # sub item_to_rss($) { my ($item) = @_; my $u = $item->{user}; my $name = $u->{full_name}; my $user = $u->{username}; my $pic = $u->{profile_pic_url}; my $date = $item->{taken_at}; my $code = $item->{code}; my $cap = ($item->{caption} ? $item->{caption}->{text} : ''); my $cap2 = $cap; $cap2 =~ s/\s+/ /gs; $cap2 =~ s/^(.{60}).*$/$1 .../gs; if (!$user) { $user = $u->{id}; } if ($item->{suggestions}) { $name = 'Instagram' unless $name; $user = 'Instagram' unless $user; $date = time() unless $date; $code = '' unless $code; $cap = $item->{title} unless $cap; $cap2 = $cap; } # landing_site_type => "suggested_user", # landing_site_title => "Discover People", # netego_type => "suggested_users", # title => "Suggested for You", return undef if ($item->{landing_site_type}); # action_type => "bake_off", # extra_data_token => "{"detailed_survey_type" ... } # message => "Quickly tell us what you like better to help us # make ads more relevant" # button_text => "Get Started", # title => "Tell Us What You Like", return undef if (($item->{action_type} || '') eq "bake_off"); print STDERR "#### no user\n" . Dumper($item) unless $user; $cap2 = "Instagram: $user" . ($cap2 ? ": $cap2" : ""); if (defined($item->{ad_link_type}) || defined($item->{dr_ad_type}) || defined($item->{injected})) { print STDERR blurb() . "omitted ad \"$cap2\"\n" if ($verbose); return (); } print STDERR blurb() . "item \"$cap2\"\n" if ($verbose); my $imgs = ($item->{video_versions} ? $item->{video_versions} : $item->{image_versions2} ? $item->{image_versions2}->{candidates} : $item->{thumbnail_resources} ? $item->{thumbnail_resources} : []); # If there are multiple photos or videos, just take the first one, sigh. # if (!@$imgs && $item->{carousel_media}) { my $cm = $item->{carousel_media}; if ($cm && @$cm) { $cm = $cm->[0]; $imgs = ($cm->{video_versions} ? $cm->{video_versions} : $cm->{image_versions2} ? $cm->{image_versions2}->{candidates} : $cm->{thumbnail_resources} ? $cm->{thumbnail_resources} : []); } } my $img; my ($size, $w, $h) = (0, 0, 0); foreach my $c (@$imgs) { my $w0 = $c->{width} || $c->{config_width} || 0; my $h0 = $c->{height} || $c->{config_height} || $w || 0; # sometimes 480x0 my $s = $w0 * $h0; if ($s > $size) { $img = $c->{url} || $c->{src}; $size = $s; $w = $w0; $h = $h0; } } my $thumb = undef; if ($item->{video_versions} && $item->{image_versions2}) { $thumb = $item->{image_versions2}->{candidates}->[0]->{url}; } elsif ($item->{video_url}) { $thumb = $item->{video_url}; } my $url = $base_url . 'p/' . $code . '/'; my $purl = $base_url . $user . '/'; my $html = ''; foreach my $s ($name, $user, $url, $purl, $img) { $s = html_quote($s) if $s; } if (0) { $html .= "" . "
" if ($pic); $html .= "$name ($user)"; $html = "
" . "$html
"; } if ($item->{suggestions}) { $cap = ''; foreach my $u (@{$item->{suggestions}}) { my $un = html_quote($u->{user}->{username}); my $cx = html_quote($u->{social_context}); $cap .= "$un $cx
"; } } elsif ($cap) { $cap = html_quote($cap); # italicize consecutive trailing #stupidity $cap =~ s@(([\@\#][^\s<>&]+\s*)+)$@$1@si; } # Caption before image, all other stuff after. $html .= "

$user" . ($cap ? ": " . $cap : "") . "

"; my $videop = ($thumb || ($img && $img =~ m/\.mp4(\?|$)/s)); my $loaded_at = strftime("%Y-%m-%d %l:%M:%S %p", localtime); if (!$img) { } elsif ($videop) { ($w, $h) = (1080, 1080) unless ($w); $html .= (""); } else { $html .= ("" . "" . ""); } if ($item->{location}) { my $id = $item->{location}->{pk}; my $url = $base_url . "explore/locations/$id/"; my $text = html_quote ($item->{location}->{name}); $html .= "

Location: $text"; } if ($item->{likers}) { my @likers = (); foreach my $like (@{$item->{likers}}) { my $uid = html_quote($like->{username}); my $url = $base_url . $uid . '/'; push @likers, "$uid"; } $html .= "

Likers: " . join (", ", @likers) if (@likers); } if ($item->{comments}) { foreach my $comm (@{$item->{comments}}) { my $text = html_quote ($comm->{text}); my $name = html_quote ($comm->{user}->{full_name}); my $uid = html_quote ($comm->{user}->{username}); my $url = $base_url . $uid . '/'; $name = $uid; $html .= "

$name: $text"; } } $html = "

$html
"; $date = strftime ("%a, %d %b %Y %H:%M:%S %Z", localtime ($date)); my $u0 = lc($user); $u0 =~ s/\s+//gs; my $n0 = lc($name); $n0 =~ s/\s+//gs; my $u2 = ($u0 eq $n0 ? $user : "$user ($name)"); my $rss = ( "\n" . " $url\n" . " $u2\n" . " " . html_quote($cap2) . "\n" . " $html]]>\n" . " $date\n" . ($img ? " \n" : "") . " "); return ($rss, $url); } sub instagram_get_json($$) { my ($ua, $url) = @_; print STDERR blurb() . "loading $url\n" if ($verbose); my $res = $ua->get ($url); my $ret = ($res && $res->code) || 'null'; my $ct = $res->header ('Content-Type') || ''; my $jsonp = ($ct =~ m@^application/json@si); if ($ret ne '200') { $ret .= " " . ($res->message || 'unknown error'); error ($ret); } error ("$url: not json") unless $jsonp; $ret = undef; eval { $ret = JSON::Any->new->jsonToObj ($res->content); }; error ("$url: unparsable json") unless $ret; $ret = $ret->{graphql} || error ("$url: no graphql"); return $ret; } sub instagram_rss($$$$) { my ($user, $tags, $likes_p, $file) = @_; $progname .= ': RSS'; # Turns out we can get JSON without logging in. # But, that only works on hashtags, not on mentions or locations. # 16-Dec-2019: Turn API back on. my $api_p = 1; my $load_feed_p = !$likes_p; my $load_mentions_p = 0; my $load_story_mentions_p = 0; my (@search_tags, @search_names, @search_locs); foreach my $t (split(/\s*[,;]*\s+/, ($tags || ''))) { if ($t =~ m/^\@(.*)$/s) { push @search_names, $1; } elsif ($t =~ m/^\#(.*)$/s) { push @search_tags, $1; } elsif ($t =~ m/^ (?: (.*?) [,;] )? ( [+-]?\d+\.\d+ ) [,;] ( [+-]?\d+\.\d+ ) $/sx) { my $n = [ ($1 || ''), $2, $3 ]; $n->[0] =~ s/[+_]/ /gs; push @search_locs, $n; } else { error ("unparsable tag: $t"); } } # If we're searching for "--tags @user" then also load story mentions, # which don't show up in search results, but rather in messages, because # that makes complete sense. # foreach my $name (@search_names) { if ($name eq $user) { $load_story_mentions_p = 1; last; } } my @items = (); my @old_entries = load_rss ($file); my $old_count = 0; my $updated_count = 0; my $unchanged_count = 0; my $expired_count = 0; my %dups; my $since = time() - int(60 * 60 * 24 * 14); my $last_id = undef; my $pages = $debug_p ? 3 : 10; my $delay = $debug_p ? 2 : 26; if ($api_p) { my $auth = instagram_login ($user); print STDERR blurb() . "logged in as $user\n" if ($verbose > 1); my $ua = $auth->{ua}; # First load the user's friend feed. # if ($load_feed_p) { for (my $i = 0; $i < $pages; $i++) { if ($i > 0) { print STDERR blurb() . "sleep $delay\n" if ($verbose); sleep ($delay); # Always delay when paginating } my $url = 'feed/timeline/'; # print STDERR blurb() . "loading $url\n" if ($verbose == 1); my $post = { _csrftoken => $auth->{csrftoken}, _uuid => $auth->{guid}, }; $post->{max_id} = $last_id if ($last_id); my $ret = instagram_load ($ua, $url, $post); # Don't cache $last_id = ($ret ? $ret->{next_max_id} : undef); my $n = $ret ? scalar(@{$ret->{items}}) : 0; print STDERR blurb() . "$n items\n" if ($verbose > 1); last unless $n; if ($ret && $ret->{items}) { foreach my $item (@{$ret->{items}}) { my $id = $item->{id}; my ($rss, $url2) = item_to_rss ($item); next unless $rss; push @items, $rss unless ($dups{$url2}); $dups{$url2} = $rss; } } last unless ($ret && $ret->{more_available}); } } # Then load mentions of that user. # Actually this is kind of horrible if your name is "jwz". # This can be accomplished by adding "--tags @user". # if ($load_mentions_p) { my $url = 'news/inbox/'; # print STDERR blurb() . "loading $url\n" if ($verbose == 1); my $ret = instagram_load_cached ($user, $ua, $url); my $i = 0; foreach my $item (@{$ret->{new_stories}}, @{$ret->{old_stories}}) { next unless ($item->{type} == 1); # "Mentioned you in a comment" my $caption = $item->{args}->{text}; my $id = $item->{args}->{media}->[0]->{id}; my $url2 = "media/$id/info/"; my $ret2 = instagram_load_cached ('ANY', $ua, $url2); foreach my $item2 (@{$ret2->{items}}) { my $caption2 = $item2->{caption} || ''; next unless ($caption2 =~ m/\b\Q$user\E\b/si); my ($rss, $url2) = item_to_rss ($item2); next unless $rss; push @items, $rss unless ($dups{$url2}); $dups{$url2} = $rss; } last if ($i++ > 25); } } # Load the #tags. # my $uuid = generate_guid(); foreach my $tag (@search_tags) { print STDERR blurb() . "loading tag \#$tag\n" if ($verbose > 1); my $url = ('feed/tag/' . $tag . '/' . '?ranked_content=false' . '&rank_token=' . $uuid); my $ret = instagram_load_cached ($user, $ua, $url); error ("$user: read feed/tag/$tag failed") unless ($ret && (ref $ret) && $ret->{status} eq 'ok'); print STDERR blurb() . "" . scalar(@{$ret->{items}}) . " items\n" if ($verbose > 1); foreach my $item (@{$ret->{items}}) { $item->{caption} = {} unless $item->{caption}; $item->{caption}->{text} = "tag \"\#$tag\": " . ($item->{caption}->{text} || ''); my ($rss, $url2) = item_to_rss ($item); next unless $rss; push @items, $rss unless ($dups{$url2}); $dups{$url2} = $rss; } } # Load the @mentions. # foreach my $n (@search_names) { print STDERR blurb() . "loading mentions \@$n\n" if ($verbose > 1); my $url = 'users/search/' . '?query=' . $n . '&ranked_content=true'; my $ret = instagram_load_cached ('ANY', $ua, $url); error ("no such user: \@$n") unless ($ret && $ret->{users}); my $id = $ret->{users}->[0]->{pk}; next unless $id; $url = "usertags/$id/feed/" . '?ranked_content=false'; $ret = instagram_load_cached ('ANY', $ua, $url); print STDERR blurb() . "" . scalar(@{$ret->{items}}) . " items\n" if ($verbose > 1); foreach my $item (@{$ret->{items}}) { $item->{caption} = {} unless $item->{caption}; $item->{caption}->{text} = "mention \"\@$n\": " . ($item->{caption}->{text} || ''); my ($rss, $url2) = item_to_rss ($item); next unless $rss; push @items, $rss unless ($dups{$url2}); $dups{$url2} = $rss; } } # Load the locations. # foreach my $place (@search_locs) { my ($pname, $lat, $lon) = @$place; print STDERR blurb() . "loading place \"$pname\" ($lat, $lon)\n" if ($verbose > 1); my $opname = $pname; my $start = time(); $place = instagram_place_id ($user, $ua, $lat, $lon, $pname); $pname = "$lat;$lon" unless $pname; error ("RSS ERROR: no such place: $pname (after " . (time() - $start) . " seconds)") unless $place; my $id = ($place->{pk} || # This shit says "redirected from unofficial page", WTF. $place->{facebook_places_id}); error ("no PK in place: $pname\n" . Dumper($place)) unless $id; #$pname .= ' (APPROX)' unless $place->{exact}; $pname = '~' . $pname unless $place->{exact}; my $url = "feed/location/$id/" . '?ranked_content=false'; my $ret = instagram_load_cached ('ANY', $ua, $url); print STDERR blurb() . "" . scalar(@{$ret->{items}}) . " items\n" if ($verbose > 1); foreach my $item (@{$ret->{items}}) { $item->{caption} = {} unless $item->{caption}; $item->{caption}->{text} = "location \"$pname\": " . ($item->{caption}->{text} || ''); my ($rss, $url2) = item_to_rss ($item); next unless $rss; push @items, $rss unless ($dups{$url2}); $dups{$url2} = $rss; } } # Load the photos liked by this user. # if ($likes_p) { $pages = 4; # 80-ish photos for (my $i = 0; $i < $pages; $i++) { sleep ($delay) if ($i > 0); # Always delay when paginating my $url = 'feed/liked/'; $url .= '?max_id=' . $last_id if ($last_id); print STDERR blurb() . "loading liked photos, page $i...\n" if ($verbose > 1); # print STDERR blurb() . "loading $url\n" if ($verbose == 1); my $ret = instagram_load_cached ($user, $ua, $url); my $n = $ret ? scalar(@{$ret->{items}}) : 0; print STDERR blurb() . "$n items\n" if ($verbose > 1); last unless $n; if ($ret && $ret->{items}) { foreach my $item (@{$ret->{items}}) { my $id = $item->{id}; my ($rss, $url2) = item_to_rss ($item); next unless $rss; push @items, $rss unless ($dups{$url2}); $dups{$url2} = $rss; $last_id = $id; } } } } # To get our mentions in other people's stories, we need to load our # messages, because they don't show up in search results. # if ($load_story_mentions_p) { foreach my $inbox ('inbox', 'pending_inbox') { my $res = instagram_load ($ua, "direct_v2/$inbox/" . '?persistentBadging=true' . '&use_unified_inbox=true'); error ("$user: read $inbox failed: $user") unless ($res && $res->{status} eq 'ok'); $res = $res->{inbox}; $res = $res->{threads} if $res; foreach my $thread ($res ? @$res : ()) { foreach my $item (@{$thread->{items} || []}) { if ($item->{item_type} eq 'reel_share' && $item->{reel_share}->{type} eq 'mention') { $item = $item->{reel_share}->{media}; next unless ($item->{image_versions2} || # Unreadable story? $item->{video_versions}); my ($rss, $url2) = item_to_rss ($item); next unless $rss; push @items, $rss unless ($dups{$url2}); $dups{$url2} = $rss; } } } } } } else { # # Don't log in, just scrape JSON. # This only works with hashtags, not with mentions or locations. # my $ua = LWP::UserAgent->new; $ua->agent ($user_agent); $ua->timeout (10); my %owner_cache; my $max = 20; foreach my $tag (@search_tags) { my $url = "https://www.instagram.com/explore/tags/$tag/?__a=1"; my $ret = instagram_get_json ($ua, $url); $ret = $ret->{hashtag} || error ("$url: no hashtag"); $ret = $ret->{edge_hashtag_to_media} || error ("$url: no edge media"); $ret = $ret->{edges} || error ("$url: no media edges"); my $count = 0; foreach my $post (@$ret) { $post = $post->{node} || error ("$url: no post node"); my $id = $post->{id} || error ("$url: no post id"); my $sc = $post->{shortcode} || error ("$url: no shortcode"); my $date = $post->{taken_at_timestamp} || error ("$url: no date"); my $img = $post->{display_url} || error ("$url: no img"); my $thumb = $post->{thumbnail_src} || error ("$url: no thumb"); my $thumbs= $post->{thumbnail_resources} || error ("$url: no thumbs"); my $owner = $post->{owner} || error ("$url: no owner"); my $cap = $post->{edge_media_to_caption} || ''; $cap = $cap->{edges}->[0]->{node}->{text} if ($cap); my $video = undef; $owner = $owner_cache{$owner->{id}} unless $owner->{username}; my $loaded = undef; if (!$owner) { $url = "https://www.instagram.com/p/$sc/?__a=1"; $ret = instagram_get_json ($ua, $url); $ret = $ret->{shortcode_media} || error ("$url: no sc media"); $loaded = $ret; $ret = $ret->{owner} || error ("$url: no owner"); $owner = $ret; $owner_cache{$owner->{id}} = $owner; } if ($post->{is_video}) { if (!$loaded) { $url = "https://www.instagram.com/p/$sc/?__a=1"; $ret = instagram_get_json ($ua, $url); $ret = $ret->{shortcode_media} || error ("$url: no sc media"); $loaded = $ret; } $video = $loaded->{video_url} || error ("$url: no video"); } my $item = { user => $owner, taken_at => $date, code => $sc, caption => { text => $cap }, thumbnail_resources => $thumbs, video_url => $video, }; my ($rss, $url2) = item_to_rss ($item); next unless $rss; push @items, $rss unless ($dups{$url2}); $dups{$url2} = $rss; last if (++$count > $max); } } } # Finally, merge with the old file contents, and write. # foreach my $item (@old_entries) { my ($date) = ($item =~ m@(.*?)<@si); my ($url) = ($item =~ m@]*>(.*?)<@si); next unless $url; my $dup = $dups{$url} || ''; $dup =~ s/^\s+//s; $dup =~ s/\s+$//s; $item =~ s/^\s+//s; $item =~ s/\s+$//s; my $odate = $date; $date = str2time ($date); if (! $date) { print STDERR blurb() . "unparsable date: $odate for $url\n"; next; # FFFffffufuuuu error ("unparsable date: $odate for $url"); } if ($date && $date < $since) { $expired_count++; print STDERR blurb() . "expired old entry for $url\n" if ($verbose > 1); } elsif (! $dup) { $old_count++; push @items, $item; print STDERR blurb() . "preserved vanished old entry for $url\n" if ($verbose > 2); } elsif ($dup eq $item) { $unchanged_count++; print STDERR blurb() . "unchanged entry for $url\n" if ($verbose > 2); } elsif (!$date) { $expired_count++; print STDERR blurb() . "expired dateless entry for $url\n" if ($verbose > 1); } else { $updated_count++; print STDERR blurb() . "updated entry for $url\n" if ($verbose > 2); } } my $total_count = @items; my $new_count = ($total_count - $old_count - $updated_count - $unchanged_count); my $desc = "$total_count entries"; $desc .= "; $new_count new"; $desc .= "; $updated_count updated"; $desc .= "; $unchanged_count unchanged"; $desc .= "; $old_count preserved"; $desc .= "; $expired_count expired"; my $oentries = join ('', @old_entries); my $nentries = join ('', @items); if ($oentries eq $nentries) { print STDERR "$file: unchanged ($desc)\n" if ($verbose); } else { # Feed validator demands a . my $rss_url = $base_url; my $rss_title = "${user}'s Instagram Feed"; my $rss = ("\n" . "\n" . " \n" . " $rss_url\n" . " $rss_title\n" . " $rss_title\n" . " en\n" . join ("\n", @items) . # newlines in $nentries " \n" . "\n"); my $file_tmp = "$file.tmp"; open (my $out, '>', $file_tmp) || error ("$file_tmp: $!"); (print $out $rss) || error ("$file_tmp: $!"); close $out; if (!rename ("$file_tmp", "$file")) { unlink "$file_tmp"; error ("mv $file_tmp $file: $!"); } print STDERR blurb() . "wrote $file ($desc)\n" if ($verbose); } } my $session_like_count = 0; sub instagram_like($$$) { my ($id, $desc, $auth) = @_; # Maybe we are liking too many things too soon. if (++$session_like_count >= 3) { print STDERR blurb() . "delaying likes\n" if ($verbose); return; } my $ua = $auth->{ua}; my $uid = $auth->{uid}; if (1) { # Don't like it again if we already liked it. # I guess this happens if we didn't read enough pages of our likes? my $res = instagram_load ($ua, "media/$id/likers/"); error ("unable to get likers of $id") unless $res; foreach my $aa (@{$res->{users}}) { if ($aa->{pk} eq $uid) { my $uname = $auth->{full_name}; print STDERR blurb() . "$uname: already liked $id!\n" if ($verbose > 2); return; } } } if (! $debug_p) { # Maybe we're hitting a rate limit for liking two things in a row? my $n = 20 + int(rand(60)); print STDERR blurb() . "like: waiting for $n...\n" if ($verbose); sleep ($n); my $res = instagram_load ($ua, "media/$id/like/", { _csrftoken => $auth->{csrftoken}, _uuid => $auth->{guid}, _uid => $auth->{uid}, media_id => "$id", }, 1); if (! ($res && ref $res && $res->{status} eq 'ok')) { $desc =~ s/\n.*$/.../s; $desc =~ s/^(.{100}).+$/$1.../s; # error ("like post failed: $desc"); print STDERR blurb() . "like post failed: $desc\n"; } } print STDERR blurb() . "like: $desc\n" if ($verbose); } # Make user1 like all the things that user2 posts. # Make user1 like all the things that user2 likes. # Make user1 follow the same users as user2. # sub instagram_sync($$$$) { my ($user1, $user2, $follow_only_p, $mentions_p) = @_; # To unfriend from every account (since they will come back fast) # my @unfriend = (); $progname .= ': sync'; my $like_likes_p = 1; # user1 likes user2's likes my $like_posts_p = !$follow_only_p; # user1 likes user2's posts my $follow_follows_p = !$follow_only_p; # user1 follows user2's follows. # Instagram started noticing this and suspending the ability to like things. # First theory was that only activity from dnalounge was getting blocked. # New theory is that dnapizza liking all of dnalounge's posts is also # triggering it. Maybe because dnapizza liked all of dnalounge's posts, # it thought that dnalounge had purchased likes from dnapziza, and penalized # dnalounge for it? # # 22-Nov-2019: we're stuck in "something went wrong" mode again so maybe not? # Turning both off. Once we're off double-secret probation try permutations. # # Things known to trigger double-secret probation: # - 111 like_posts_p like_likes_p mentions_p # - 011 like_likes_p mentions_p # Unclear: # - 101 like_posts_p mentions_p # - 110 like_posts_p like_likes_p # - 001 mentions_p # - 010 like_likes_p # - 100 like_posts_p # $like_posts_p = 0; $like_likes_p = 0; $mentions_p = 0; my $auth1 = instagram_login ($user1); # Maybe it hates 2 logins right after each other? # Getting a constant stream of checkpoint_required that can't be # checkpointed when logging in on the fucking phone. # Nope. # sleep (30) unless ($debug_p); my $auth2 = instagram_login ($user2); my $ua1 = $auth1->{ua}; my $ua2 = $auth2->{ua}; my $uid1 = $auth1->{uid}; my $uid2 = $auth2->{uid}; my $guid1 = $auth1->{guid}; my $guid2 = $auth2->{guid}; my $csrf1 = $auth1->{csrftoken}; my $csrf2 = $auth2->{csrftoken}; error ("no uid for $user1") unless $uid1; error ("no uid for $user2") unless $uid2; error ("no guid for $user1") unless $guid1; error ("no guid for $user2") unless $guid2; error ("no csrftoken for $user1") unless $csrf1; error ("no csrftoken for $user2") unless $csrf2; # Ignore stuff older than N hours. If cron hasn't hit those by now. # something is wrong and we're probably wasting our time re-hitting them. my $since = time() - int(60 * 60 * 2); #$since -= (60 * 60 * 24 * 2) if ($debug_p); # Go back a little farther for likes, since the date is the date of the # liked post, not the date that we liked it. my $since2 = $since - (60 * 60 * 24 * 2); my $delay = $debug_p ? 2 : 26; my $pages = 4; # # Make user1 follow the same users as user2. # my %friends; if ($follow_follows_p || $follow_only_p || $mentions_p) { my $url = "friendships/$uid1/following/"; my $i = 0; while ($i < 20) { $i++; my $res = instagram_load_cached ('ANY', $ua1, $url); error ("unable to read $user1 friends page $i") unless ($res->{users} && @{$res->{users}}); print STDERR blurb() . "$user1: read friends page $i (" . scalar (@{$res->{users}}) . ")\n" if ($verbose > 2); foreach my $item (@{$res->{users}}) { my $user3 = $item->{username}; $friends{$user3} = $item; print STDERR blurb() . " $user1 follows $user3\n" if ($verbose > 3); } last unless ($res->{next_max_id}); $url =~ s/\?.*$//s; $url .= '?max_id=' . $res->{next_max_id}; } foreach my $f (@unfriend) { if (!$friends{$f}) { $friends{$f} = 2; # don't re-friend these } } } if ($follow_follows_p) { my $url = "friendships/$uid2/following/"; my $i = 0; # while ($i < 20) { while ($i < 10) { # too many? Is it chronological? $i++; my $res = instagram_load_cached ('ANY', $ua1, $url); error ("unable to read $user2 friends page $i") unless ($res->{users} && @{$res->{users}}); print STDERR blurb() . "$user2: read friends page $i (" . scalar (@{$res->{users}}) . ")\n" if ($verbose > 2); foreach my $item (@{$res->{users}}) { my $user3 = $item->{username}; if ($friends{$user3}) { print STDERR blurb() . "already following ${user2}'s friend $user3\n" if ($verbose > 2); next; } if ($item->{is_private}) { print STDERR blurb() . "skipping private friend of $user2: $user3\n" if ($verbose > 2); next; } if ($user1 eq $user3) { print STDERR blurb() . "skipping self: $user2 $url\n" if ($verbose > 2); next; } my $res2 = instagram_load_cached ('ANY', $ua1, "users/$user3/usernameinfo/"); error ("unable to look up user $user3") unless ($res2 && $res2->{user} && $res2->{user}->{pk}); my $id = $res2->{user}->{pk}; if (! $debug_p) { $res2 = instagram_load ($ua1, "friendships/create/$id/", { _csrftoken => "$csrf1", _uuid => "$guid1", _uid => "$uid1", user_id => "$id", }); error ("$user1: add friend failed: $user2 $user3") unless ($res2 && $res2->{status} eq 'ok'); } print STDERR blurb() . "$user1 add ${user2}'s friend $user3 $id\n" if ($verbose); $friends{$user3} = $res2->{user}; } last unless ($res->{next_max_id}); $url =~ s/\?.*$//s; $url .= '?max_id=' . $res->{next_max_id}; } } my %liked; if ($like_posts_p || $like_likes_p || $mentions_p) { my $url = "feed/liked/"; $pages = 4; # 80-ish photos (I once said 5 pages seems to be too few?) my $i = 0; while ($i < $pages) { $i++; sleep ($delay) if ($i > 1); # Always delay when paginating my $res = instagram_load_cached ($user1, $ua1, $url); error ("unable to read $user1 likes") unless ($res->{items} && @{$res->{items}}); print STDERR blurb() . "$user1: read likes page $i (" . scalar (@{$res->{items}}) . ")\n" if ($verbose > 2); foreach my $item (@{$res->{items}}) { my $id = $item->{id}; $liked{$id} = $item; if ($verbose > 2) { my $code = $item->{code}; my $date = $item->{taken_at}; $date = strftime ("%a %b %d", localtime ($date)); my $url = $base_url . "p/$code/"; print STDERR blurb() . " $user1 liked: $date: $url $id\n"; } } last unless ($res->{next_max_id}); $url =~ s/\?.*$//s; $url .= '?max_id=' . $res->{next_max_id}; } } # # Make user1 like all the things that user2 posts. # if ($like_posts_p) { my $res = instagram_load_cached ('ANY', $ua2, "feed/user/$uid2/"); error ("unable to read $user2 posts") unless ($res->{items} && @{$res->{items}}); print STDERR blurb() . "$user2: read posts\n" if ($verbose > 2); foreach my $item (@{$res->{items}}) { my $id = $item->{id}; my $code = $item->{code}; my $date = $item->{taken_at}; my $url = $base_url . "p/$code/"; if ($date < $since) { my $n = $since - $date; print STDERR blurb() . "skipping old: $user2 $url ($n)\n" if ($verbose > 2); next; } if ($liked{$id}) { print STDERR blurb() . "already liked: $user2 $url\n" if ($verbose > 2); next; } instagram_like ($id, "${user2}'s post $url", $auth1); } } # # Make user1 like all the things that user2 likes. # if ($like_likes_p) { # KLUDGE if ($follow_only_p) { undef $friends{'dnalounge'}; undef $friends{'dnapizza'}; undef $friends{'jwz'}; } my $url = "feed/liked/"; $pages = 3; # 60-ish photos my $i = 0; while ($i < $pages) { $i++; sleep ($delay) if ($i > 1); # Always delay when paginating my $res = instagram_load_cached ($user2, $ua2, $url); error ("unable to read $user2 likes page $i") unless ($res->{items} && @{$res->{items}}); print STDERR blurb() . "$user2: read likes page $i (" . scalar (@{$res->{items}}) . ")\n" if ($verbose > 2); foreach my $item (@{$res->{items}}) { my $id = $item->{id}; my $code = $item->{code}; my $user3 = $item->{user}->{username}; my $date = $item->{taken_at}; my $url = $base_url . "p/$code/"; if ($date < $since2) { my $n = $since2 - $date; print STDERR blurb() . "skipping old: ${user2}'s" . " like of $user3 $url ($n)\n" if ($verbose > 2); next; } if ($liked{$id}) { print STDERR blurb() . "already liked: ${user2}'s" . " like of $user3 $url\n" if ($verbose > 2); next; } if ($user1 eq $user3) { print STDERR blurb() . "skipping self: $user2 $url\n" if ($verbose > 2); next; } if ($follow_only_p && !$friends{$user3}) { print STDERR blurb() . "skipping post that ${user2}" . " liked by ${user3} $url: not a friend\n" if ($verbose > 1); next; } instagram_like ($id, "$user2 liked ${user3}'s post $url", $auth1); } last unless ($res->{next_max_id}); $url =~ s/\?.*$//s; $url .= '?max_id=' . $res->{next_max_id}; } } # # Make user1 like any mentions of them made by any followers. # if ($mentions_p) { # Look at Inbox for "mentioned you" my $res = instagram_load_cached ($user1, $ua1, "news/inbox/"); error ("$user1: read news/inbox failed") unless ($res && $res->{status} eq 'ok'); # Search for our user name used as a tag my $uuid = generate_guid(); my $res2 = instagram_load_cached ($user1, $ua1, 'feed/tag/' . $user1 . '/' . '?ranked_content=false' . '&rank_token=' . $uuid); $res2 = undef unless (ref $res2); # Might be string "500 Internal Error" # error ("$user1: read feed/tag/$user1 failed") print STDERR "$user1: read feed/tag/$user1 failed\n" unless ($res2 && (ref $res2) && $res2->{status} eq 'ok'); # If this user is a place, search for posts at that geolocation too. my $res3; my $place = $geo_by_user{$user1}; if ($place) { my ($lat, $lon, $fbid) = split(/\s*[,;]\s*/, $place); $place = instagram_place_id ($user1, $ua1, $lat, $lon, $fbid); $place = $place->{facebook_places_id} if $place; $res3 = instagram_load_cached ($user1, $ua1, "feed/location/$place/") if $place; } foreach my $story (#@{$res->{old_stories}}, #@{$res->{new_stories}}, ($res2 ? @{$res2->{items}} : ()), ($res3 ? @{$res3->{items}} : ()), ) { $story = $story->{args} if ($story->{args}); my $text = (($story->{caption} ? $story->{caption}->{text} : $story->{text}) || ''); my $user3 = (($story->{user} ? $story->{user}->{username} : $story->{profile_name}) || '???'); my $id = ($story->{id} || ($story->{media} && @{$story->{media}} ? $story->{media}[0]->{id} : 0)); next if ($user1 eq $user3); next if ($text =~ m/^\Q$user3\E liked your post/si); next if ($text =~ m/^\Q$user3\E started following you/si); next if ($text =~ m/^\Q$user3\E commented: /si); next if (($story->{profile_image_destination} || $story->{destination} || '') =~ m/^(follower_list|webview)$/s); $text =~ s/\n+/ /gs; if (! $id) { print STDERR "#### no ID\n" . Dumper($story); next; } if (! $friends{$user3}) { print STDERR blurb() . "$user1 mention from non-friend $user3:" . " $text\n" if ($verbose > 2); next; } if ($id && $liked{$id}) { print STDERR blurb() . "$user1 already liked: ${user3}'s mention:" . " $text\n" if ($verbose > 2); next; } # print STDERR "$user1 liked ${user3}'s mention: $text\n"; instagram_like ($id, "$user1 liked ${user3}'s mention: $text", $auth1); } } # # To unfriend from every account (since they will come back fast) # foreach my $user3 (@unfriend) { if (($friends{$user3} || 0) == 2) { print STDERR blurb() . "no need for $user1 to unfriend $user3\n" ;#### if ($verbose); next; } my $res2 = instagram_load_cached ('ANY', $ua1, "users/$user3/usernameinfo/"); error ("unable to look up user $user3") unless ($res2 && $res2->{user} && $res2->{user}->{pk}); my $id = $res2->{user}->{pk}; if ($debug_p) { print STDERR blurb() . "$user1 would have unfriended $user3\n" if ($verbose); } else { $res2 = instagram_load ($ua1, "friendships/destroy/$id/", { _csrftoken => "$csrf1", _uuid => "$guid1", _uid => "$uid1", user_id => "$id", }, 1); if ($res2 && $res2->{status} eq 'ok') { print STDERR blurb() . "$user1 unfriended $user3\n" ;#### if ($verbose); } else { print STDERR blurb() . "$user1: unfriend failed for $user3\n"; } } } } # Make $user add $follow as a friend. # sub instagram_follow($$$) { my ($user, $followp, $follow) = @_; $progname .= ': ' . ($followp ? 'follow' : 'unfollow'); $follow =~ s@^https?://[^/]+/([^/?&]+).*$@$1@s; $follow =~ s/^@//s; my $auth = instagram_login ($user); my $ua = $auth->{ua}; my $res = instagram_load_cached ('ANY', $ua, "users/$follow/usernameinfo/"); error ("unable to look up user $follow") unless ($res && $res->{user} && $res->{user}->{pk}); my $id = $res->{user}->{pk}; return if $debug_p; $res = instagram_load ($ua, ($followp ? "friendships/create/$id/" : "friendships/destroy/$id/"), { _csrftoken => $auth->{csrftoken}, _uuid => $auth->{guid}, _uid => $auth->{uid}, user_id => "$id", }); error ("$user: " . ($followp ? "add" : "remove") . " friend failed: $user $follow") unless ($res && $res->{status} eq 'ok'); print STDERR blurb() . "$follow $id\n" if ($verbose); } # Does $user exist? # sub instagram_ping($$) { my ($user, $ping) = @_; $progname .= ': ping'; $ping =~ s@^https?://[^/]+/([^/?&]+).*$@$1@s; $ping =~ s/^@//s; my $auth = instagram_login ($user); my $ua = $auth->{ua}; my $res = instagram_load_cached ('ANY', $ua, "users/$ping/usernameinfo/"); error ("no such user: $ping") unless ($res && $res->{user} && $res->{user}->{pk}); my $id = $res->{user}->{pk}; print STDERR blurb() . "$ping $id\n" if ($verbose); return $id; } # Isn't it great how Instagram has three different places where messages # can show up, and in each, any actual communication is completely swamped # by ephemera? # # - The "heart" tab at the bottom: # - XYZ liked your post <-- Noise! # - XYZ started following you <-- Noise! # - XYZ mentioned you in a comment <-- Actual message! # - The "home / paper airplane" tab in the upper right: # - XYZ mentioned you in their story <-- Noise! # - XYZ sent you a message <-- Actual message! # - The "home / paper airplane / Other" tab # - XYZ (not a friend) mentioned you in their story <-- Noise! # - XYZ (not a friend) sent you a message <-- Actual message! # # Yeah, we really need three different places for this crap, *EACH* of which # manages to drown real, explicitly-addressed messages in stalky-mentions, # with no way to filter out the chaff. # sub instagram_messages($) { my ($user) = @_; $progname .= ': msgs'; error ("instagram_messages - nope"); my $auth = instagram_login ($user); my $ua = $auth->{ua}; my $res = instagram_load_cached ('ANY', $ua, "users/$user/usernameinfo/"); error ("unable to look up my uid") unless ($res && $res->{user} && $res->{user}->{pk}); my $my_uid = $res->{user}->{pk}; my %users; $users{$my_uid} = $user; my @msgs; my $inboxp = 1; my $messagesp = 1; # This part is for direct messages (from friends, and strangers). # #### Threads can actually have multiple participants. But I've never # seen one of those, so this probably doesn't handle that sensibly. # if ($messagesp) { foreach my $inbox ('inbox', 'pending_inbox') { $res = instagram_load ($ua, "direct_v2/$inbox/" . '?persistentBadging=true' . '&use_unified_inbox=true'); error ("$user: read $inbox failed: $user") unless ($res && $res->{status} eq 'ok'); $res = $res->{inbox}; $res = $res->{threads} if $res; foreach my $thread ($res ? @$res : ()) { #### I don't know what to do with $thread->{oldest_cursor} # to get the previous items in the thread. foreach my $item (@{$thread->{items} || []}) { foreach $user (@{$thread->{users} || []}) { $users{$user->{pk}} = $user->{username} if $user->{pk}; } my $iid = $item->{item_id}; my $uid = $item->{user_id}; my $time = int (($item->{timestamp} || 0) / 1000000); my $text = undef; if ($item->{item_type} eq 'reel_share') { if ($item->{reel_share}->{type} eq 'mention') { my $u = $item->{reel_share}->{media}->{user}->{username}; $text = "$u mentioned you in their story"; } elsif ($item->{reel_share}->{type} eq 'reply' || $item->{reel_share}->{type} eq 'reaction') { my $uid = $item->{user_id}; my $u = $users{$uid} || "[$uid]"; $text = "$u replied to your story: " . $item->{reel_share}->{text}; my $url = ($item->{reel_share}->{media}->{image_versions2} ->{candidates}->[0]->{url}) if ($item->{reel_share}->{media}); $url =~ s/\?.*//s if $url; $text .= "\n$url" if $url; #### Note, text might be encoded like "\x{1f4af}" } else { error ("unknown reel_share type: " . Dumper($item)); } } elsif ($item->{item_type} eq 'media' || $item->{item_type} eq 'raven_media') { my $u = $users{$uid} || "[$uid]"; my $url = ($item->{media}->{image_versions2} ->{candidates}->[0]->{url}); $url = '???' unless $url; $url =~ s/\?.*//s; $text = "$u tagged you: $url"; } elsif ($item->{item_type} eq 'text') { my $u = $users{$uid} || "[$uid]"; $u .= ' => ' . $thread->{thread_title} if ($uid eq $my_uid); $text = "$u sent you a message: " . $item->{text}; } elsif ($item->{item_type} eq 'link') { my $u = $users{$uid} || "[$uid]"; $u .= ' => ' . $thread->{thread_title} if ($uid eq $my_uid); $text = "$u sent you a link: " . $item->{link}->{text}; } elsif ($item->{item_type} eq 'media_share') { my $u = $users{$uid} || "[$uid]"; $u .= ' => ' . $thread->{thread_title} if ($uid eq $my_uid); my $img = $item->{media_share}->{image_versions2} ->{candidates}->[0]->{url}; $img =~ s/\?.*//s if $img; $text = "$u shared a link: " . $item->{media_share}->{caption}->{text}; $text .= "\n" . $img if $img; } elsif ($item->{item_type} eq 'story_share') { my $u = $users{$uid} || "[$uid]"; $u .= ' => ' . $thread->{thread_title} if ($uid eq $my_uid); my $img = $item->{story_share}->{image_versions2} ->{candidates}->[0]->{url}; $img =~ s/\?.*//s if $img; $text = "$u shared a link"; $text .= ': ' . $item->{story_share}->{media}->{caption}->{text} if ($item->{story_share}->{media}->{caption}); $text .= "\n" . $img if $img; } elsif ($item->{item_type} eq 'felix_share') { my $u = $users{$uid} || "[$uid]"; $u .= ' => ' . $thread->{thread_title} if ($uid eq $my_uid); my $img = $item->{felix_share}->{image_versions2} ->{candidates}->[0]->{url}; $img =~ s/\?.*//s if $img; $text = "$u shared a link"; $text .= ': ' . $item->{felix_share}->{media}->{caption}->{text} if ($item->{felix_share}->{media}->{caption}); $text .= "\n" . $img if $img; } elsif ($item->{item_type} eq 'placeholder') { # "Update your app to see this post" my $u = $users{$uid} || "[$uid]"; $u .= ' => ' . $thread->{thread_title} if ($uid eq $my_uid); $text = "$u sent you an unreadable message: " . $item->{placeholder}->{message}; } elsif ($item->{item_type} eq 'like') { # Ignore, I guess } elsif ($item->{item_type} eq 'action_log') { # Ignore, I guess # description = "X liked your mention in their story" } elsif ($item->{item_type} eq 'live_viewer_invite') { # Ignore, I guess } else { error ("unknown item_type: " . Dumper($item)); } push @msgs, [ $time, $text ] if defined($text); } } } } # This part is "...mentioned you in a comment", "...liked your post". # if ($inboxp) { $res = instagram_load ($ua, "news/inbox/"); error ("$user: read news/inbox failed: $user") unless ($res && $res->{status} eq 'ok'); foreach my $story (@{$res->{old_stories}}, @{$res->{new_stories}}) { $story = $story->{args}; my $time = int($story->{timestamp}); # it's a float, with microseconds my $text = $story->{text} || ''; my $img = $story->{media} ? $story->{media}->[0]->{image} : undef; # If we remove the _nc_ht and ig_cache_key we eventually get # "URL signature mismatch". # $img =~ s/\?.*//s if $img; next if ($text =~ m/^[-_.a-zA-Z\d]+ liked your (photo|video)\.?$/s); next if ($text =~ m/^[-_.a-zA-Z\d]+ started following you\.?$/s); next if ($text =~ m/^[-_.a-zA-Z\d]+ tagged you in a post\.?$/s); next if ($text =~ m/^This post is doing better than /s); next if ($text =~ m/^\s*$/s); $text .= " $img" if $img; push @msgs, [ $time, $text ]; } } @msgs = sort { $a->[0] <=> $b->[0] } @msgs; foreach my $msg (@msgs) { my ($time, $text) = @$msg; $text =~ s/\\/\\\\/gs; $text =~ s/\n/\\n/gs; print STDOUT "" . localtime($time) . "\t$text\n"; } } sub instagram_delete($$$) { my ($user, $from, $to) = @_; 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); $progname .= ': del'; my $auth = instagram_login ($user); my $ua = $auth->{ua}; # A delay of 3 seconds is ok, but eventually triggers rate limiting. # Maybe longer will upset the AI less. # If we had posted 10x a day, and delete old posts once a week at # ~90 seconds per deletion, this should take around 1:45:00 to finish. # At 135 seconds: 2:37:30. # # perl -e 'my $delay = 90; my $s = (7 * 10 * $delay * 1.5); \ # print sprintf("%d:%02d:%02d\n", $s/60/60, ($s/60)%60, $s%60);' # my $delay = 90; my $n = 1; my $done = 0; my $seen = 0; my $deleted = 0; my %dup_captions; my $url = "feed/user/" . $auth->{uid} . "/"; while (1) { print STDERR blurb() . "loading page $n...\n" if ($verbose); my $res = instagram_load ($ua, $url); error ("feed failed") unless ($res && $res->{status} eq 'ok'); foreach my $p (@{$res->{items}}) { my $id = $p->{id}; my $txt = $p->{caption}->{text} || ''; my $date = $p->{taken_at}; my $code = $p->{code}; my $link = $base_url . "p/$code/"; my $type = $p->{video_versions} ? 'VIDEO' : 'PHOTO'; #### or 'CAROUSEL' # Delete any post if there is a newer post with the same caption # (including blank). This catches made-redundant posts like the # "Coming up at DNA Lounge" video. # my $dup_p = $dup_captions{$txt}; $dup_captions{$txt} = 1; $txt = "[DUP] $txt" if ($dup_p); $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 ($date >= $to && !$dup_p) { print STDERR blurb() . "keeping: $ds: $txt $link\n" if ($verbose > 1); } elsif ($date < $from) { print STDERR blurb() . "done - reached $ds ($ofrom)\n" if ($verbose); $done = 1; } elsif ($debug_p) { print STDERR blurb() . "would have deleted: $ds: $txt $link\n" if ($verbose); $deleted++; } else { my $res2 = instagram_load ($ua, "media/$id/delete/" . "?media_type=$type", { _csrftoken => $auth->{csrftoken}, _uuid => $auth->{guid}, _uid => $auth->{uid}, media_id => "$id", }, 1); # Might be string "500 read timeout" $res2 = undef unless (ref $res2); if ($res2 && $res2->{status} eq 'ok') { print STDERR blurb() . "deleted: $ds: $txt\n" if ($verbose); $deleted++; } else { print STDERR blurb() . "deleting FAILED: $ds: $txt $link\n"; # Maybe you can't delete a post that has been boosted? } sleep ($delay + (rand() * $delay)); # So annoying } last if $done; } last if $done; last unless ($res->{next_max_id}); $url =~ s/\?.*$//s; $url .= '?max_id=' . $res->{next_max_id}; $n++; sleep ($delay + (rand() * $delay)); # Always delay when paginating } my $kept = $seen - $deleted; print STDERR blurb() . "deleted $deleted, kept $kept of $seen\n" if ($verbose); } sub instagram_follower_count($$) { my ($user, $user2) = @_; $progname .= ': count'; my $auth = instagram_login ($user); my $ua = $auth->{ua}; $user2 = $user if (!$user2 || $user2 eq '1'); my $res = instagram_load_cached ('ANY', $ua, "users/$user2/usernameinfo/"); error ("no userinfo for $user2") unless ($res && $res->{user} && $res->{user}->{pk}); print STDOUT (($res->{user}->{full_name} || $res->{user}->{username}) . "\t" . $res->{user}->{follower_count} . "\n"); } # For simply loading the HTML of an Instagram page with our login cookie. # sub instagram_load_html($$) { my ($user, $url) = @_; $progname .= ': html'; my $auth = instagram_login ($user); my $ua = $auth->{ua}; my $res = $ua->get ($url); my $ret = ($res && $res->code) || 'null'; error ($ret) if ($ret ne '200'); my $html = $res->content; utf8::decode($html); # Parse multi-byte UTF-8 into wide chars. print STDOUT $html; } sub error($) { my ($err) = @_; die $err; # Caught in main } sub usage(;$) { my ($err) = @_; print STDERR blurb() . "$err\n" if $err; print STDERR "usage: $progname user [--verbose] [--debug] [--caption txt]\n"; print STDERR "\t\t\t\t[--story] [--location lat,lon[,fbid]]\n"; print STDERR "\t\t\t\t[--link URL] [--countdown DATE TEXT]\n"; print STDERR "\t\t\t\t--image image-file-or-url\n\n"; print STDERR "\t\t or: user --rss outfile\n"; print STDERR "\t\t\t\t--tags \"#tag1 #tag2 \@user1 \@user2\"\n"; print STDERR "\t\t\t\t--tags \"location1;LAT;LONG location2;LAT;LONG\"\n"; print STDERR "\t\t\t\t--sync user2 [--followed-only] [--mentions]\n"; print STDERR "\t\t\t\t--follow user2\n"; print STDERR "\t\t\t\t--ping user2\n"; print STDERR "\t\t\t\t--delete FROM TO\n"; print STDERR "\t\t\t\t--load-html instagram-url\n"; exit 1; } sub main() { my $user = undef; my $file = undef; my $fallback_image = undef; my $caption = undef; my $location = undef; my $rssp = 0; my $tags = ''; my $likes_p = 0; my $story_p = 0; my $crop_p = 0; my $countdown = undef; my $link = undef; my $sync = undef; my $mentions_p = 0; my $messages_p = 0; my $count_p = 0; my $fonly = undef; my $follow = undef; my $ping = undef; my $unfollow = undef; my $load_html = undef; my $delete_from = undef; my $delete_to = undef; 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/^--?link$/) { $link = shift @ARGV; } elsif (m/^--?location$/){ $location = shift @ARGV; } elsif (m/^--?rss$/) { $rssp = 1; } elsif (m/^--?likes?$/) { $likes_p = 1; } elsif (m/^--?story$/) { $story_p = 1; } elsif (m/^--?crop$/) { $crop_p = 1; } elsif (m/^--?pad$/) { $crop_p = 0; } elsif (m/^--?tags$/) { $tags .= ' ' if $tags; $tags .= shift @ARGV; } elsif (m/^--?sync$/) { $sync = shift @ARGV; } elsif (m/^--mentions$/) { $mentions_p = 1; } elsif (m/^--?messages$/){ $messages_p = 1; } elsif (m/^--?followed(-only)$/) { $fonly = 1; } elsif (m/^--?follow$/) { $follow = shift @ARGV; } elsif (m/^--?ping$/) { $ping = shift @ARGV; } elsif (m/^--?unfollow$/) { $unfollow = shift @ARGV; } elsif (m/^--?delete?$/) { $delete_from = shift (@ARGV); $delete_to = shift (@ARGV); } elsif (m/^--?follower-count$/) { $count_p = 1; $count_p = shift @ARGV if (($ARGV[0] || '') =~ m/^[^-]/s); } elsif (m/^--?fallback(-image)?$/) { $fallback_image = shift @ARGV; } elsif (m/^--?load(-html)?$/) { $load_html = shift @ARGV; } elsif (m/^--?countdown$/) { my $date = shift @ARGV; my $text = shift @ARGV; usage("--countdown DATE TEXT") unless ($date && $text); $date = str2time ($date) || error ("unparsable date: $date"); $countdown = [ $date, $text ]; } elsif (m/^--?image$/) { $file = shift @ARGV; } elsif (m/^-./) { usage ("unknown: $_"); } elsif (!$user) { $user = $_; } elsif (!$file) { $file = $_; } # "--image" is optional else { usage ("unknown: $_"); } } usage("no user") unless ($user); usage("no file") unless ($file || $sync || $follow || $unfollow || $ping || $messages_p || $count_p || $load_html || $delete_from); usage("--caption doesn't work with --rss") if ($rssp && $caption); usage("--caption doesn't work with --sync") if ($sync && $caption); usage("--caption doesn't work with --follow") if ($follow && $caption); usage("--caption doesn't work with --unfollow") if ($unfollow && $caption); usage("--caption doesn't work with --messages") if ($messages_p && $caption); usage("file doesn't make sense with --sync") if ($sync && $file); usage("file doesn't make sense with --follow") if ($follow && $file); usage("file doesn't make sense with --unfollow") if ($unfollow && $file); usage("file doesn't make sense with --messages") if ($messages_p && $file); usage("--fallback doesn't make sense with --sync") if ($sync && $fallback_image); usage("--fallback doesn't make sense with --follow") if ($follow && $fallback_image); usage("--fallback doesn't make sense with --unfollow") if ($unfollow && $fallback_image); usage("--fallback doesn't make sense with --messages") if ($messages_p && $fallback_image); usage("--story doesn't make sense with --rss") if ($rssp && $story_p); usage("--story doesn't make sense with --sync") if ($sync && $story_p); usage("--story doesn't make sense with --follow") if ($follow && $story_p); usage("--story doesn't make sense with --unfollow") if ($unfollow && $story_p); usage("--story doesn't make sense with --messages")if($messages_p&&$story_p); usage("--countdown only works with --story") if ($countdown && !$story_p); $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 = " "; binmode (STDOUT, ':utf8'); binmode (STDERR, ':utf8'); # Don't print "bless( do{\(my $o = 0)}, 'JSON::PP::Boolean' )" for "false". # Aaaaaugh why isn't this working any more # $JSON::PP::true = 1; # $JSON::PP::false = 0; # $JSON::XS::true = 1; # $JSON::XS::false = 0; # $Types::Serialiser::true = 1; # $Types::Serialiser::false = 0; if ($caption) { $caption =~ s/\\n/\n/gs; # Sigh $caption =~ s/\\//gs; } $fallback_image = undef if ($file && $fallback_image && $file eq $fallback_image); eval { if ($rssp) { # 16-Dec-2019: Maybe working again? # Always works if we scrape HTML instead of using the API. instagram_rss ($user, $tags, $likes_p, $file); } elsif ($sync) { # 16-Dec-2019: Maybe working again? instagram_sync ($user, $sync, $fonly, $mentions_p); } elsif ($follow) { instagram_follow ($user, 1, $follow); } elsif ($unfollow) { instagram_follow ($user, 0, $unfollow); } elsif ($ping) { instagram_ping ($user, $ping); } elsif ($messages_p) { # 16-Dec-2019: Maybe working again? instagram_messages ($user); } elsif ($count_p) { instagram_follower_count ($user, $count_p); } elsif ($load_html) { instagram_load_html ($user, $load_html); } elsif ($delete_from) { instagram_delete ($user, $delete_from, $delete_to); } else { usage("--tags only works with --rss") if $tags; usage("--likes only works with --rss") if $likes_p; instagram_upload_retry ($user, $caption, $location, $file, $fallback_image, $story_p, $crop_p, $countdown, $link); } }; if ($@) { # error() called and un-caught. print STDERR blurb() . "$@\n"; if ($caption || $file) { print STDERR blurb() . "failed to post:" . ($caption ? " \"$caption\"" : "") . ($file ? " $file" : "") . "\n"; } exit 1; } } main(); exit 0;