#!/usr/bin/perl -w # Copyright © 2010-2012 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. # # # # # # NOTE: This code is pretty much obsolete and will stop working # eventually, as Facebook has deprecated the API that it # use. # # you should probably take a look at my "facebook-rss.pl" # program instead, which does most of what this did, but # using a not-yet-deprecated API. # # # # # If you are using "Facebook Connect" to add a Facebook comments box on your # web site, Facebook gives you no way to be notified when someone has posted # a new comment -- I guess you are expected to just go and hit reload on each # of your pages that accepts comments to see when a new one has arrived? # # So, this script uses the Facebook API to get a list of all comments that # have been made on your pages within the last week and construct an RSS feed # out of them. You have to give it the authorization credentials of your # Facebook Application (the same application you had to create to enable # comments via Facebook Connect in the first place.) # # The application's secret keys are stored in a file in your home directory. # Create $HOME/.APPNAME-facebook-pass, with each key on its own line like: # # API_KEY: 3d26xxxx... # SESSION_KEY: 61a7xxxx... # SECRET: 8cfbxxxx... # # Make that file unreadable by others, it's dangerous. # # You get the API_KEY and SECRET from your application's admin page. # You create the SECRET by running this script as: # fbcomments.pl dnalounge --generate-session # and following its instructions. The key it generates lasts forever, # so you only need to do this once. # # # Examples of usage: # # # - Create an RSS feed of recent comments posted on the DNA Lounge page # on Facebook, http://www.facebook.com/dnalounge -- # # fbcomments.pl dnalounge --page 'DNA Lounge' out.rss # # # - Create an RSS feed of recent check-ins to the DNA Lounge "place" page: # # fbcomments.pl dnalounge --checkins 'DNA Lounge' out.rss # # # - Create an RSS feed of recent comments posted via Facebook Connect on # the DNA Lounge web site -- # # fbcomments.pl dnalounge out.rss # # (Actually this mode doesn't work any more, because Facebook fucked # up access to external Facebook Connect comments: you can no longer # get them via FQL! So now I use Disqus on my web site instead.) # # # - Create an RSS feed of photos recently uploaded by your friends # (that is, friends of the logged in user who has the app installed, # not friends of the app itself) -- # # fbcomments.pl dnalounge --page 'photos' out.rss # # # - Create an RSS feed of photos in which your friends have recently been # tagged -- # # fbcomments.pl dnalounge --page 'tagged-photos' out.rss # # # - Create an RSS feed of posts by all of the Pages you have "liked" -- # # fbcomments.pl dnalounge --page 'pages' out.rss # # # - Create an RSS feed of the logged in user's entire news feed: # # fbcomments.pl dnalounge --page 'stream' out.rss # # - Create an RSS feed of the logged in user's entire news feed, # excluding posts from "pages" (that is, updates from "friends", # but no updates from things of which you are a "fan"): # # fbcomments.pl dnalounge --page 'stream-friends' out.rss # # - Print out a list of all of your friends' birthdays (this is not RSS): # # fbcomments.pl dnalounge --page 'birthdays' out.rss # # # The last few only work if you have added the app to your personal # Facebook account and given it complete read-access to your stream. # You may or may not consider this a safe thing to do. It allows anyone # with access to the secret keys of the Application to access anything # in your personal account. If you are the only person with access to # the application, then it's fine. If your coworkers also control the # application, then this is the same as giving all of them your personal # Facebook password. # # You give an application permissions on the "Account / Privacy Settings / # Applications and Websites" page. # # # Kill files: $HOME/.APPNAME-facebook-kill can contain a list of user names. # posts by those users are excluded from the RSS feed. (You could also # just unfriend those people, but ignoring them might be kinder.) # # You may need to add the names of blocked apps to your killfile, too. # It used to be that if you had blocked a friend's app (by clicking on # the X in the stream) then that app wouldn't show up here, but that # seems to have changed recently. # # You can also add "Firstname Lastname photos" to block photo posts from # a given user but allow others. # # With the --wxr option, instead of writing an XML file, it writes a # WordPress Export Format XML file. With this you can (for example) # import your old Facebook comments into Disqus. # # # Created: 27-Sep-2010. require 5; use diagnostics; use strict; use POSIX; use WWW::Facebook::API; use Data::Dumper; my $progname = $0; $progname =~ s@.*/@@g; my $version = q{ $Revision: 1.55 $ }; $version =~ s/^[^\d]+([\d.]+).*/$1/; my $verbose = 0; # Upper limit on number of photos in the stream at one time, so that if # someone posts a hundred photos at once, you are only spammed with the # first N. # my $max_photos_per_user = 10; sub make_fb_client($$$) { my ($api_key, $secret, $session_key) = @_; my $client = WWW::Facebook::API->new( parse => 1, throw_errors => ($verbose ? 1 : 0), ); $client->api_key ($api_key); $client->secret ($secret); $client->desktop (1); $client->session_key ($session_key) if $session_key; return $client; } # Read $HOME/.APPNAME-facebook-pass. # sub load_passwords($;$) { my ($user, $gen_sess_p) = @_; my ($api_key, $secret, $session_key); my $file = $ENV{HOME} . "/.$user-facebook-pass"; open (my $in, '<', $file) || error ("$file: $!"); while (<$in>) { if (m/^API_KEY:\s*(.*?)\s*$/s) { $api_key = $1; } elsif (m/^SECRET:\s*(.*?)\s*$/s) { $secret = $1; } elsif (m/^SESSION_KEY:\s*(.*?)\s*$/s) { $session_key = $1; } # elsif (m/[^\s]/s) { error ("$file: unparsable: $_"); } } close $in; error ("$file: no API_KEY") unless $api_key; error ("$file: no SECRET") unless $secret; error ("$file: no SESSION_KEY") unless ($session_key || $gen_sess_p); return ($api_key, $secret, $session_key); } # Read $HOME/.APPNAME-facebook-kill, return a hashref of users to ignore. # sub load_killfile($) { my ($user) = @_; my $file = $ENV{HOME} . "/.$user-facebook-kill"; my %users; if (open (my $in, '<', $file)) { while (<$in>) { s/^\s+|\s+$//g; s/\s*#.*$//s; next unless $_; $users{lc($_)} = 1; } close $in; } return \%users; } xn sub interactive_generate_session_key($) { my ($user) = @_; my ($api_key, $secret, $session_key) = load_passwords ($user, 1); my $client = make_fb_client ($api_key, $secret, undef); print STDOUT "1) Go to " . $client->get_infinite_session_url . "\n"; print STDOUT "2) Press 'Generate' and get a token.\n"; print STDOUT "3) Enter the token now: "; chomp (my $token = <>); # Get a session $client->auth->get_session ($token); $session_key = $client->session_key; my $session_secret = $client->secret; print STDOUT "4) Your infinite session key is: $session_key\n"; print STDOUT "5) Your infinite session secret is: $session_secret\n\n"; print STDOUT "Put these in ~/.$user-facebook-pass\n"; print STDOUT "You only need to do this once\n\n"; exit 0; } sub html_quote($) { my ($s) = @_; return undef unless defined ($s); $s =~ s/&/&/gs; $s =~ s//>/gs; return $s; } my $wxr_p = 0; # Wrap RSS boilerplate around the list of s, and write the file # if it has changed. # sub write_rss($$$) { my ($title, $outfile, $items) = @_; # Feed validator demands a . my $url = 'http://www.facebook.com/'; my $rss = ("\n" . "\n" . " \n" . " $url\n" . " $title\n" . " $title\n" . " en\n" . ($wxr_p ? (" 1.1\n" . " $url\n" . " $url\n") : "")); if ($wxr_p) { my %idmap; my $idcount = 0; my %items2; foreach my $item (@$items) { my ($id) = ($item =~ m@]*>(.*?)@si); my ($com) = ($item =~ m@(.*?)\s*@si); my $L = $items2{$id}; my @L = ($L ? @$L : ()); push @L, $com; $items2{$id} = \@L; $idmap{$id} = (++$idcount) unless defined $idmap{$id}; } my @items2; foreach my $id (sort keys %items2) { my $url = convert_xid_to_url (undef, $id, undef); if ($url !~ m/^http:/s) { print STDERR "$progname: WARNING: not a URL: $url\n"; } else { my $id2 = $idmap{$id}; my @comms = @{$items2{$id}}; # Just pick the date of the first comment. Doesn't matter. my ($dates) = ($comms[0] =~ m@(.*?)@si); $dates =~ s@comment_date@post_date@gs; my $item = (" \n" . " $url\n" . " $url\n" . " \n" . " \n" . " $dates\n" . " $id2\n" . " closed\n" . " closed\n" . " publish\n" . " 0\n" . " 0\n" . " post\n" . " \n" . " 0\n" . " Uncategorized\n" ); foreach my $com (@comms) { $item .= (" " . "$com\n" . " \n"); } $item .= " "; push @items2, $item; } } $items = \@items2; } my %users; foreach my $item (@$items) { my ($user) = ($item =~ m@<(?:author|dc:creator)\b[^<>]*>(.*)\n" . "\n"); my $nusers = scalar (keys %users); my $nitems = $#{$items} + 1; my $desc = " ($nitems items from $nusers users)"; my $old = ''; if (open (my $in, '<', $outfile)) { local $/ = undef; # read entire file $old = <$in>; close $in; } if ($rss eq $old) { print STDERR "$outfile: unchanged$desc\n" if ($verbose); } else { my $file_tmp = "$outfile.tmp"; open (my $out, '>', $file_tmp) || error ("$file_tmp: $!"); (print $out $rss) || error ("$file_tmp: $!"); close $out; if (!rename ("$file_tmp", "$outfile")) { unlink "$file_tmp"; error ("mv $file_tmp $outfile: $!"); } print STDERR "$progname: wrote $outfile$desc\n" if ($verbose); } } # Given a list of hashes: # # ( { "id" => id1, "x" => x1, "y" => "y1", ... }, # { "id" => id2, "x" => x2, "y" => "y2", ... }, ... ) # # Convert it to a keyed hash: # # { id1 => { "x" => x1, "y" => "y1", ... }, # id2 => { "x" => x2, "y" => "y2", ... }, ... } # sub fb_table_to_hash($@) { my ($key, @listrefs) = @_; my %ret; foreach my $t (@listrefs) { foreach my $r (@$t) { $ret{$r->{$key}} = $r; } } return \%ret; } # Given a list of hashes: # # ( { "id" => id1, "x" => x1, ... }, # { "id" => id1, "x" => x2, ... }, # { "id" => id2, "x" => x3, ... }, # { "id" => id2, "x" => x4, ... }, ... ) # # Convert it to a hash of lists: # # { id1 => [ "x1", "x2", ... ], # id2 => [ "x3", "x4", ... ], ... } # sub fb_table_to_hash_list($$@) { my ($key, $val, @listrefs) = @_; my %ret; foreach my $t (@listrefs) { foreach my $r (@$t) { my $kk = $r->{$key}; my $vv = $r->{$val}; my $L = $ret{$kk}; my @L = $L ? @$L : (); push @L, $vv; $ret{$kk} = \@L; } } return \%ret; } # Submit an fql.multiquery request and return the result, # after reformatting the inputs and outputs more sanely. # # Args: # # fb_multiquery ($client_object, "name1" => "fql1", "name2" => "fql2" ... ) # # Returns: # # { "name1" => ( "value1a" ... ), "name2" => ( "value2a" ... ) ... } # sub fb_multiquery(%) { my ($client, %q) = @_; if ($verbose > 1) { $Data::Dumper::Indent = 1; $Data::Dumper::Terse = 1; $Data::Dumper::Quotekeys = 0; $Data::Dumper::Pair = "\t=> "; $Data::Dumper::Pad = " "; print STDERR "\nQueries:\n" . Dumper(\%q); } # The client->fql->multiquery command wants the queries packed up # into a single JSON string, so this converts the array arg to that. my $json = ''; foreach my $name (keys %q) { my $fql = $q{$name}; $fql =~ s/[\\\"]/\\"/gs; $fql =~ s/\s+/ /gs; $json .= "," if $json; $json .= "\"$name\": \"$fql\""; } $json = "{$json}"; print STDERR "\nJSON: $json\n" if ($verbose > 1); my $ret = undef; eval { $ret = $client->fql->multiquery (queries => $json); }; if (! $ret) { #error ("multiquery: null response"); print STDERR "$progname: multiquery: null response\n" if ($verbose > 1); return undef; } if (ref($ret) eq 'HASH' && $ret->{error_msg}) { # error ("multiquery: " . $ret->{error_msg}) print STDERR "$progname: multiquery: " . $ret->{error_msg} . "\n" if ($verbose > 1); return undef; } # The result in $ret is a reference to a list of hashes, where each # hash has two values. The actual query results are nested inside # the hashes: # # ( { "name" => N1, "fql_result_set" => ( X1, Y1, ... ) }, # { "name" => N2, "fql_result_set" => ( X2, Y2, ... ) }, ... ) # # Flatten all of that mess to a single hash: # # { N1 => ( X1, Y1, ... ), # N2 => ( X2, Y2, ... ), ... } # my %ret; foreach my $pair (@$ret) { my $key = $pair->{name}; my $val = $pair->{fql_result_set}; # If there are no matches, then multiquery returns an empty hash instead # of an empty list! WTF! Convert it, for convenience and sanity. # if (ref($val) ne 'ARRAY') { my @L = (); $val = \@L; } $ret{$key} = $val; } print STDERR "\nResult:\n" . Dumper(\%ret) . "\n" if ($verbose > 1); return \%ret; } # Facebook fails often, so this retries a few times until we get results, # if the response was empty. # sub fb_multiquery_retry($$%) { my ($file, $client, %q) = @_; my $retries = 10; for (my $i = 0; $i < $retries; $i++) { my $ret = fb_multiquery ($client, %q); if (! defined($ret)) { my %P; $ret = \%P; } # Some result in the multi-query must have at least 1 value. my $count = 0; foreach my $k (keys (%$ret)) { my $v = $ret->{$k}; $count += scalar (@$v); } return $ret if ($count > 0); print STDERR "$progname: multiquery failed, retrying...\n" if ($verbose > 1); sleep 10 + ($i * 2) # 10 tries => 162 seconds if ($i < $retries-1); } error ("$file: no results in multiquery (after $retries tries)"); } # This makes an effort to convert the XID of a comment back to the # page it happened on, since Facebook doesn't give us that info. # sub convert_xid_to_url($$$) { my ($page_url, $xid, $id) = @_; if (!$page_url && $xid) { if ($xid =~ m@\b(\d{4})-(\d\d)-(\d\d[b-z]?)$@s) { # Hey, looks like one of mine... $page_url = "http://www.dnalounge.com/backstage/log/$1/$2/$3.html"; $xid = ''; } } if ($xid && $xid =~ m/^https?:/si) { # If the xid is a URL, that's probably a better one to use. $page_url = $xid; $xid = ''; } if ($page_url) { $page_url .= ($page_url =~ m/#/s ? '.' : '#'); } else { $page_url = ''; } $page_url .= $xid if $xid; $page_url .= '.' if ($xid && $id); $page_url .= $id if $id; $page_url =~ s/#$//s; return $page_url; } # Creates an HTML blob of a userpic and the user's name, floating left, # given a UID and a table-ref of users' info. # sub userpic_html($$;$$) { my ($uid, $users, $app_id, $apps) = @_; my $uu = $users->{$uid}; my $user = html_quote ($uu->{name} || ''); my $uurl = html_quote ($uu->{url} || ''); my $uimg = html_quote ($uu->{pic_square} || ''); my $html = $user; $html = "
$html" if $uimg; $html = "$html" if $uurl; if ($app_id) { my $a = $apps->{$app_id}; my $app_name = $a->{display_name}; my $app_url = $a->{canvas_name}; $app_url = "https://apps.facebook.com/$app_url" if $app_url; $app_name = "$app_name" if $app_url; # If there's no URL, it's a FB builtin like "Events". $html .= " via $app_name" if $app_url; } $html = "
" . "$html
"; return $html; } # Creates an HTML blob of all comments on this post, given a list-ref # of the comment objects and a table-ref of users' info. # sub comments_html($$$$$$) { my ($comments, $parent_id, $users, $likes, $friends, $max_comments) = @_; my $html = ''; my $count = 0; my $skipped = 0; foreach my $c ($comments ? @$comments : ()) { next unless ($parent_id eq ($c->{object_id} || $c->{post_id})); if ($max_comments && ++$count > $max_comments) { $skipped++; next; } my $time = $c->{time} || $c->{created_time}; my $from = $c->{fromid} || $c->{actor_id}; my $user = html_quote ($users->{$from}->{name}); my $text = html_quote ($c->{text} || $c->{message} || ''); my $date = POSIX::strftime ("%a %b %d, %I:%M %p", localtime ($time)); $html .= "

"; $html .= userpic_html ($from, $users); # enlinkenate URLs. $text =~ s@(https?://[^\s]+[a-z\d/])@$1@gsi; $html .= "

$date
"; $text =~ s/\n/
/gs; $html .= $text; $html .= "

"; } if ($skipped) { $html .= "

" . "...$skipped comments omitted.

"; } # Prepend the HTML with "X, Y and Z like this." # if ($likes) { my @u = (); my $max = 10; my $lcount = 0; # The friends list looks like: # ( { 'target_id' => AAA }, { 'target_id' => BBB }, ... ) # convert it to a single hash: # { AAA => 1, BBB => 1, ... } # my %friendp; if ($friends) { foreach my $L (@$friends) { my $id = $L->{target_id}; $friendp{$id} = 1; } } foreach my $like (@$likes) { my $uid = $like->{user_id}; my $post = $like->{post_id}; next unless ($post eq $parent_id); # not for this post next if ($friends && !$friendp{$uid}); # skip non-friends $lcount++; next if ($lcount > $max); my $uu = $users->{$uid}; my $user = html_quote ($uu->{name} || ''); my $uurl = html_quote ($uu->{url} || ''); my $uhtml = $user; $uhtml = "$uhtml" if $uurl; push @u, $uhtml; } if ($lcount > 0) { my $lhtml = ($lcount < $max ? (($#u > 0 ? join(", ", @u[0..($#u-1)]) . " and " : "") . $u[$#u] . " " . ($lcount == 1 ? "likes" : "like") . " this.") : (join(", ", @u) . " and " . ($lcount - $max) . " others like this.")); $html = "$lhtml

$html"; $count += $lcount; } } $html = "


$html" if $count; return $html; } # Creates an HTML blob of an attachment, given an attachment object. # sub attachment_html($$) { my ($a, $text) = @_; my $html = ''; my $name = $a->{name}; my $desc = $a->{description}; my $cap = $a->{caption}; my $aurl; if (ref($a->{media}) eq 'ARRAY') { foreach my $m (@{$a->{media}}) { my $url = $m->{href}; my $src = $m->{src}; my $alt = $m->{alt}; my $maxw = 'max-width:24em;'; $html .= "

"; $alt = '' if ($alt eq $text || $alt eq ($name || '')); $alt = $url if ($url && !$src && !$alt); $html .= "" if $url; $html .= "" if $src; $html .= "
" . html_quote($alt) if $alt; $html .= "
" if $url; $html .= "
"; $aurl = $url if $url; } } $aurl = $a->{href} unless $aurl; $html .= "

"; if ($name) { $html .= "" if $aurl; $html .= "

" . $name . "
"; # no html_quote -- contains " $html .= "" if $aurl; } $html .= "
$cap
" if $cap; # no html_quote - can have if ($desc) { # Blah, sometimes all kinds of Facebook markup in there! # $html .= html_quote($desc); $desc =~ s@]*>@@gsi; $desc =~ s@(]*>@$1>@gsi; $desc =~ s@\b(onmouse[a-z]+|rel|target)="[^"]*"@@gsi; $html .= $desc; } $html = rewrite_html ($html); return $html; } # Check whether we have the permissions we're about to use, for sensible # error messages instead of just null data. # sub check_perms($$$) { my ($file, $perm, $client) = @_; my $ret = undef; my $retries = 10; for (my $i = 0; $i < $retries; $i++) { eval { $ret = $client->users->has_app_permission (ext_perm => $perm); }; last if defined($ret); print STDERR "$progname: API failed, retrying...\n" if ($verbose); sleep 5 + $i; } if (defined ($ret) && $ret eq 0) { # Figure out what URL to load to fix this. my $api_key = $client->{api_key}; $ret = fb_multiquery_retry ($file, $client, "app" => "SELECT app_id, display_name FROM application WHERE api_key = '$api_key'"); my $id = @{$ret->{app}}[0]->{app_id}; my $name = @{$ret->{app}}[0]->{display_name}; my $url = ("https://graph.facebook.com/oauth/authorize?" . "client_id=" . $id . "&redirect_uri=http://www.facebook.com/" . "&scope=$perm"); error ("the \"$name\" app does not have \"$perm\" permission.\n" . "\t\tTo add that permission, load this URL:\n\n" . "\t\t$url\n"); } elsif (defined ($ret) && $ret eq 1) { # good } else { error ($ret->{error_msg}) if (defined($ret) && ref($ret) eq 'HASH' && $ret->{error_msg}); error ("check_perms: $file: unknown response: " . ($ret || '')); } } sub truncate_sentence($) { my ($s) = @_; # If there is a sentence-end between 30 and 60 chars, stop there. $s =~ s/(.{30}[.?!]\s).*$/$1/s; # Else if there is a word-end between 40 and 60 chars, stop there. $s =~ s/(.{40}[^\s]+).*$/$1 .../s; # Else truncate at 60 chars regardless. $s =~ s/^(.{60}).+/$1 .../s; return $s; } # Hack the HTML bodies to make them more readable. # sub rewrite_html($) { my ($body) = @_; # Instead of going through the app proxy, go to the URL directly # so that we get larger inline images. # $body =~ s@\b( https?://www\.facebook\.com/app_full_proxy.php [^<>\"\']+ | https?://[^/<>\'\"]*?\.fbcdn\.net/safe_image.php [^<>\"\']+ ) @{ my $url = $1; my ($redir) = ($url =~ m/(?:src|url)=(http[^&]+)/s); if ($redir) { $redir =~ s/%([\da-f][\da-f])/{chr(hex($1))}/xige; $url = $redir; } $url; }@gsexi; # Use larger Tumblr images. # $body =~ s@\b(https?://[^/<>\"\']*?\.tumblr\.com[^<>\"\']*?)_250\.jpg @${1}_500.jpg@gsix; return $body; } sub retrieve_comments($$$) { my ($user, $page, $outfile) = @_; my ($api_key, $secret, $session_key) = load_passwords ($user); my $client = make_fb_client ($api_key, $secret, $session_key); my $kill = load_killfile($user); my ($desc, $title, $ret, $q); my @items = (); my $since = time() - (60 * 60 * 24 * 7); # 1 week $since -= (60 * 60 * 24 * 365 * 10) # 10 years if ($wxr_p); my $max_inline_comments = 50; my $checkin_p = 0; $checkin_p = 1 if ($page && $page =~ s/^CI\t//s); my $no_pages = 0; if ($page && $page eq 'stream-friends') { $page = 'stream'; $no_pages = 1; } if ($page && ($page eq 'photos' || $page eq 'tagged-photos')) { check_perms ($outfile, 'friends_photos', $client); # To read photos, 5 queries: # - all albums of my friends, modified this week; # - in that, all photos created this week; # - in that, all users. # - also, all comments on those photos; # - and all users on those comments. my $ret = fb_multiquery_retry ($outfile, $client, "friend_ids" => "SELECT uid2 FROM friend WHERE uid1 = me()", "albums" => "SELECT aid FROM album WHERE owner IN (SELECT uid2 FROM #friend_ids) AND modified_major > $since", "photos" => ($page eq 'photos' ? "SELECT link, src_big, owner, caption, created, pid, object_id FROM photo WHERE aid IN (SELECT aid FROM #albums) AND created > $since ORDER BY created desc" # Tagged photos instead of posted photos. : "SELECT link, src_big, owner, caption, created, pid, object_id FROM photo WHERE object_id IN (SELECT object_id FROM photo_tag WHERE subject IN (SELECT uid2 FROM #friend_ids) AND created > $since) ORDER BY created desc"), # Friends tagged in each photo "tags" => "SELECT object_id, subject FROM photo_tag WHERE object_id IN (SELECT object_id FROM #photos) ORDER BY created asc", # Comments on each photo "comments" => "SELECT fromid, username, text, time, id, xid, object_id FROM comment WHERE object_id IN (SELECT object_id FROM #photos) ORDER BY time asc", # Users of each photo "users" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT owner FROM #photos)", # Users of each comment "users2" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT fromid FROM #comments)", # Users of each tag "users3" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT subject FROM #tags)" ); # Iterate the "photos" result and create an RSS . # my $users = fb_table_to_hash ("id", $ret->{users}, $ret->{users2}, $ret->{users3}); my $tags = fb_table_to_hash_list ("object_id", "subject", $ret->{tags}); my %users_photo_count; foreach my $r (@{$ret->{photos}}) { my $link = html_quote ($r->{link}); my $img = html_quote ($r->{src_big}); my $uid = $r->{owner}; my $date = $r->{created}; my $cap = $r->{caption} || ''; my $user = ($users->{$uid}->{name} || ''); my $count = ($users_photo_count{$uid} || 0) + 1; $users_photo_count{$uid} = $count; if ($count > $max_photos_per_user) { print STDERR "$progname: skipping photo $count for $user\n" if ($verbose > 1); next; } if ($user && ($kill->{lc($user)} || $kill->{lc($user) . " photos"})) { print STDERR "$progname: killfile: $user\n" if ($verbose > 1); next; } $cap =~ s/\r\n/\n/gs; my $cap2 = $cap; $cap2 =~ s/\n.*$//s; $cap2 = truncate_sentence ($cap2); my $ititle = html_quote ($user . ($cap2 ? ": $cap2" : " posted a photo")); $cap = html_quote ($cap); $cap =~ s/\n/
/gs; my $date2 = POSIX::strftime ("%a %b %d, %I:%M %p", localtime ($date)); $date = POSIX::strftime ("%a, %d %b %Y %H:%M:%S %Z", localtime ($date)); my $html = userpic_html ($uid, $users); # Link, date, and caption. $html .= "$date2

"; $html .= "

" . "
" . "$cap

" if $cap; # The image; $html .= ""; $html .= ""; $html .= ""; $html = "

$html
"; # Tagged users. # my @thtml = (); my $tusers = $tags->{$r->{object_id}}; foreach my $uid ($tusers ? @$tusers : ()) { my $uu = $users->{$uid}; next unless $uu; my $user = html_quote ($uu->{name}); my $uurl = html_quote ($uu->{url} || ''); my $uhtml = $user; $uhtml = "$uhtml" if $uurl; push @thtml, $uhtml; } $html .= "

Tagged: " . join(', ', @thtml) . ".

" if (@thtml); # Comments. $html .= comments_html ($ret->{comments}, $r->{object_id}, $users, undef, undef, 0); push @items, (" \n" . " $link\n" . # " nobody\@facebook.com ($user)\n" . # " $user\n" . # technically illegal, don't care. " $user\n" . " $ititle\n" . " $html]]>\n" . " $date\n" . " \n"); } $title = ($page eq 'photos' ? "Recent Photos" : "Recent Tagged Photos"); } elsif ($page && $page eq 'birthdays') { check_perms ($outfile, 'friends_birthday', $client); my $ret = fb_multiquery_retry ($outfile, $client, "bdays" => "SELECT uid, birthday FROM user WHERE uid IN (SELECT uid2 FROM friend where uid1 = me())", # Users of each photo "users" => "SELECT id, name FROM profile WHERE id IN (SELECT uid FROM #bdays)", ); # Iterate the "photos" result and create an RSS . # my @ret = (); my $users = fb_table_to_hash ("id", $ret->{users}); foreach my $r (@{$ret->{bdays}}) { my $uid = $r->{uid}; my $bday = $r->{birthday}; next unless $bday; my $name = ($users->{$uid}->{name} || ''); push @ret, "$name\t$bday"; } print STDOUT join("\n", sort (@ret)) . "\n"; exit 0; } else { # Reading comments (from an app or from a page). my ($page_url, $app_name, $ret); if ($page && $page eq 'pages') { check_perms ($outfile, 'user_likes', $client); check_perms ($outfile, 'read_stream', $client); # To read comments from the pages of which I am a fan, 5 queries: # - get the page IDs of the pages in question; # - in that, get all entries in the stream this week; # - in that, get all corresponding comments; # - in that, all users; # - and also, info about those pages (via stream). # - also the comments on each post; # - and the users on those comments. $ret = fb_multiquery_retry ($outfile, $client, "mypages" => "SELECT page_id FROM page_fan WHERE uid = me()", "stream" => "SELECT post_id, source_id, created_time, actor_id, post_id, message, permalink, attachment, app_id FROM stream WHERE source_id IN (SELECT page_id FROM #mypages) AND updated_time > $since", "users" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT actor_id FROM #stream)", "pages" => "SELECT page_id, name, page_url FROM page WHERE page_id IN (SELECT source_id FROM #stream)", # Comments on this stream-post from the page. "comments2" => "SELECT fromid, username, text, time, id, xid, post_id FROM comment WHERE post_id IN (SELECT post_id from #stream) ORDER BY time asc", # Users in the comments. "users2" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT fromid FROM #comments2)", # Names of apps that posted these updates. # "canvas_name" no longer exists? "apps" => "SELECT app_id, display_name, company_name, description FROM application WHERE app_id IN (SELECT app_id FROM #stream)"); $title = "Facebook Pages"; } elsif ($page && $page eq 'stream') { check_perms ($outfile, 'read_stream', $client); # To read comments from friends and pages of which I am a fan: # - get the IDs of the people/pages in question; # - in that, get all entries in the stream this week; # - in that, get all corresponding comments; # - in that, all users; # - and also, info about those pages (via stream). # - also the comments on each post; # - and the users on those comments. # # - To exclude pages (with "stream-friends"), we also need a list # of pages of which I'm a fan. $ret = fb_multiquery_retry ($outfile, $client, "stream" => "SELECT post_id, source_id, created_time, actor_id, target_id, post_id, message, permalink, attachment, app_id FROM stream WHERE source_id IN (select target_id FROM connection WHERE source_id = me()) AND updated_time > $since", # Pages of which I am a fan. "mypages" => "SELECT page_id FROM page_fan WHERE uid = me()", # Users who are authors of a post. "users" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT actor_id FROM #stream)", # Users who are targets of a post. "users2" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT target_id FROM #stream)", # Comments on this stream-post. "comments2" => "SELECT fromid, username, text, time, id, xid, post_id FROM comment WHERE post_id IN (SELECT post_id from #stream) ORDER BY time asc", # Users in the comments. "users3" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT fromid FROM #comments2)", # Names of apps that posted these updates. # "canvas_name" no longer exists? "apps" => "SELECT app_id, display_name, company_name, description FROM application WHERE app_id IN (SELECT app_id FROM #stream)", # # Likes on this post that are also from my friends. # # This times out, not sure why. # # You'd think it would be better to filter on the server side # # and send less data back, but sending it all and filtering on # # the client is the only thing that works. # # # "likes" => "SELECT user_id, post_id # FROM like # WHERE post_id # IN (SELECT post_id from #stream) # AND user_id # IN (select target_id # FROM connection # WHERE source_id = me())", # Likes on all of these stream posts. "likes" => "SELECT user_id, post_id FROM like WHERE post_id IN (SELECT post_id from #stream)", # All of my friends. "friends" => "SELECT target_id FROM connection WHERE source_id = me()", # Users who liked things. "users4" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT user_id FROM #likes)", ); $title = "Facebook Stream"; } elsif ($checkin_p) { check_perms ($outfile, 'user_checkins', $client); check_perms ($outfile, 'friends_checkins', $client); # To read checkins to a place page, 4 queries: # - get the page ID of the named page; # - in that, get all checkins; # - in that, all users. $ret = fb_multiquery_retry ($outfile, $client, # Don't understand why LIMIT 1 is needed, but "source_id IN (...)" # fails if the IN has more than one element in the result. # "page" => "SELECT page_id, page_url FROM page WHERE name = '$page' LIMIT 1", # I dunno, I'm getting nothing here, not even my friends' checkins. # In the test console at # http://developers.facebook.com/docs/reference/rest/fql.query/ # I can run the example from here: # http://developers.facebook.com/docs/reference/fql/checkin/ # but only with "Test Console", not "DNA Lounge". # Also I can query that particular checkin by author or post, # but not by page. # author_uid='7901103' # page_id='105751846144711' # post_id='7901103_10100368462460150' # "checkins" => "SELECT author_uid, post_id, timestamp, message # FROM checkin # WHERE page_id IN (SELECT page_id FROM #page) # ORDER BY timestamp asc", "checkins" => "SELECT author_uid, post_id, timestamp, message FROM checkin WHERE post_id='7901103_10100368462460150' ORDER BY timestamp asc", # posts to which these are comments "parents" => "SELECT post_id, source_id, created_time, actor_id, post_id, message, permalink, attachment FROM stream WHERE post_id IN (SELECT post_id FROM #checkins)", "users" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT author_uid FROM #checkins)"); my $p = $ret->{page} || error ("$outfile: no page in multiquery?" . " ##1\n" . Dumper(\$ret)); $p = @{$p}[0] || error ("$outfile: no page in multiquery?" . " ##2\n" . Dumper(\$ret)); $page_url = $p && $p->{page_url}; $title = "$page Checkins"; } elsif ($page) { # To read comments from a page, 4 queries: # - get the page ID of the named page; # - in that, get all comments this week; # - in that, all users. # - also, get the stream posts to which those comments were made. $ret = fb_multiquery_retry ($outfile, $client, # Don't understand why LIMIT 1 is needed, but "source_id IN (...)" # fails if the IN has more than one element in the result. # "pages" => "SELECT page_id, page_url, name FROM page WHERE name = '$page' ORDER BY strlen(page_url) ASC", # "page" => "SELECT page_id, page_url, name # FROM #pages # WHERE name = '$page' # LIMIT 1", # WHAT THE FUCKING FUCK! #### # SELECT page_id, page_url, name FROM page WHERE name = 'DNA Pizza' # stopped working, but # SELECT page_id, page_url, name FROM page WHERE name = 'DNA Lounge' # still works fine!! # "page" => ($page eq 'DNA Pizza' ? "SELECT page_id, page_url, name FROM page WHERE page_id = '192927147394687'" : "SELECT page_id, page_url, name FROM #pages WHERE name = '$page' LIMIT 1"), "comments" => "SELECT fromid, username, text, time, id, xid, post_id FROM comment WHERE post_id IN (SELECT post_id FROM stream WHERE source_id IN (SELECT page_id FROM #page) AND updated_time > $since) ORDER BY time asc", # posts to which these are comments "parents" => "SELECT post_id, source_id, created_time, actor_id, post_id, message, permalink, attachment FROM stream WHERE post_id IN (SELECT post_id FROM #comments)", "users" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT fromid FROM #comments)"); my $p = $ret->{page} || error ("$outfile: no page in multiquery?" . " ##3\n" . Dumper(\$ret)); $p = @{$p}[0] || error ("$outfile: no page in multiquery?" . " ##4\n" . Dumper(\$ret)); $page_url = $p && $p->{page_url}; $title = "$page Comments"; } else { # To read comments from an app, 3 queries: # - get the app ID for the API key; # - in that, get all comments this week; # - in that, all users. $ret = fb_multiquery_retry ($outfile, $client, "app" => "SELECT app_id, display_name FROM application WHERE api_key = '$api_key'", "comments" => "SELECT fromid, text, time, id, xid FROM comment WHERE xid IN (SELECT xid FROM comments_info WHERE app_id IN (SELECT app_id FROM #app) AND updated_time > '$since') ORDER BY time asc", "users" => "SELECT id, name, pic_square, url FROM profile WHERE id IN (SELECT fromid FROM #comments)"); $app_name = @{$ret->{app}}[0]->{display_name}; $title = "$app_name Comments"; } # Iterate the "comments" result and create an RSS . # my $users = fb_table_to_hash ("id", $ret->{users}, $ret->{users2}, $ret->{users3}, $ret->{users4}); my $parents = fb_table_to_hash ("post_id", $ret->{parents}) if $ret->{parents}; my ($pages, $pages2, $stream, $apps); if ($ret->{pages}) { $pages = fb_table_to_hash ("page_id", $ret->{pages}); $stream = fb_table_to_hash ("post_id", $ret->{stream}); } $pages2 = fb_table_to_hash ("page_id", $ret->{mypages}) if $ret->{mypages}; $apps = fb_table_to_hash ("app_id", $ret->{apps}) if ($ret->{apps}); my @c; push @c, @{$ret->{stream}} if $ret->{stream}; push @c, @{$ret->{comments}} if $ret->{comments}; @c = sort { ($a->{time} || $a->{created_time}) <=> ($b->{time} || $b->{created_time}) } @c; foreach my $r (@c) { my $time = $r->{time} || $r->{created_time}; my $from = $r->{fromid} || $r->{actor_id}; my $id = $r->{id} || $r->{post_id}; my $to = $r->{target_id}; my $app_id = $r->{app_id}; my $xid = $r->{xid}; my $link = html_quote ($r->{permalink}); my $user = html_quote ($users->{$from}->{name}); my $text = html_quote ($r->{text} || $r->{message}); my ($app_name, $app_co, $app_desc, $app_url); if ($app_id) { my $a = $apps->{$app_id}; $app_name = $a->{display_name}; $app_co = $a->{company_name}; $app_desc = $a->{description}; } # Kill files work on user names, app names, or app company names. # Also on "USER via APP". # Also kills if the string is a subset of "description", since # some of the Zynga games have no developer or company_name set! if ($user && $kill->{lc($user)}) { print STDERR "$progname: killfile: $user\n" if ($verbose > 1); next; } if ($app_name && $kill->{lc($app_name)}) { print STDERR "$progname: killfile app: $app_name\n" if ($verbose > 1); next; } if ($app_co && $kill->{lc($app_co)}) { print STDERR "$progname: killfile app: $app_co\n" if ($verbose > 1); next; } if ($app_desc) { my $killed = 0; foreach my $s (keys %$kill) { if ($app_desc =~ m/\Q$s/i) { print STDERR "$progname: killfile app desc: $s\n" if ($verbose > 1); $killed = 1; last; } } next if $killed; } if ($user && $app_name && $kill->{lc("$user via $app_name")}) { print STDERR "$progname: killfile user/app: $user/$app_name\n" if ($verbose > 1); next; } if ($no_pages && $from && $pages2->{$from}) { print STDERR "$progname: omitting page post: $user, $id\n" if ($verbose > 1); next; } my $fmt = $wxr_p ? "%Y-%m-%d %H:%M:%S" : "%a, %d %b %Y %H:%M:%S %Z"; my $date = POSIX::strftime ($fmt, localtime ($time)); my $date_gmt = POSIX::strftime ($fmt, gmtime ($time)); my $html = userpic_html ($from, $users, $app_id, $apps); my $p0 = $page; my $p2; if ($stream && $stream->{$r->{post_id}}) { my $sid = $stream->{$r->{post_id}}->{source_id}; my $p = $pages->{$sid}; $p0 = $p->{name}; $p2 = $p0; $page_url = $p->{page_url}; } elsif ($page && $page eq 'stream') { } elsif ($page) { $p2 = $page; } my $parent = $parents ? $parents->{$r->{post_id}} : undef; my $in_re = undef; if ($parent) { my $purl = $parent->{permalink}; $in_re = ($parent->{message} || ($parent->{attachment} && $parent->{attachment}->{name})); if ($in_re) { $in_re = html_quote (truncate_sentence ($in_re)); $html .= "In reply to: \"$in_re\" "; } } if ($p2) { $p2 = "$p2" if $page_url; $html .= " on $p2" unless (!$p0 || $user eq $p0); } # enlinkenate URLs. my $otext = $text; $text =~ s@\b(https?://[^\s]+?[a-z\d/])(\s|$) @$1$2@gsix; if ($to) { $to = $users->{$to}; my $turl = html_quote ($to->{url} || ''); my $tname = html_quote ($to->{name} || '?'); $tname = "$tname" if $turl; $text = "→ $tname: $text"; } $html .= "

$text"; $html =~ s/\n/
/gs; $html .= attachment_html ($r->{attachment}, $text) if $r->{attachment}; # Inline the comments on this post, if any. $html .= comments_html ($ret->{comments2}, $r->{post_id}, $users, $ret->{likes}, $ret->{friends}, $max_inline_comments); my $subj = ''; if ($r->{message}) { $subj = $r->{message}; } elsif ($r->{attachment} && $r->{attachment}->{name}) { $subj = $r->{attachment}->{name}; } $subj = html_quote (truncate_sentence ($subj)); my $ititle = ($in_re ? html_quote ("Re: $in_re") : $subj ? $subj : $page ? ("Comment from $user" . ($user eq $p0 ? '' : " on $p0")) : $app_name ? "$app_name: Comment from $user" : "Comment from $user"); $link = convert_xid_to_url ($page_url, $xid, $id) unless $link; my $perm = ($link =~ m@^http@s ? "true" : "false"); push @items, ($wxr_p ? (" \n" . " $xid\n" . " \n" . " $id\n" . " \n" . " \n" . " \n" . " \n" . " $date\n" . " $date_gmt\n" . " \n". " 1\n" . " \n" . " 0\n" . " 0\n" . " \n" . " \n") : (" \n" . " $link\n" . # " nobody\@facebook.com ($user)\n" . # " $user\n" . # technically illegal, don't care. " $user\n" . " $ititle\n" . " $otext\n" . " $html]]>\n" . " $date\n" . " \n")); } } write_rss ($title, $outfile, \@items); } sub error($) { my ($err) = @_; print STDERR "$progname: $err\n"; exit 1; } sub usage() { print STDERR "usage: $progname [--verbose] appname [--page|--checkin ID] [--wxr] outfile\n"; print STDERR "usage: $progname [--verbose] appname --generate-session\n"; exit 1; } sub main() { my ($app, $page, $file, $gen_p); while ($#ARGV >= 0) { $_ = shift @ARGV; if (m/^--?verbose$/) { $verbose++; } elsif (m/^-v+$/) { $verbose += length($_)-1; } elsif (m/^--?page$/) { $page = shift @ARGV; } elsif (m/^--?checkins?$/) { $page = "CI\t" . shift @ARGV; } elsif (m/^--?generate-(session)?$/) { $gen_p = 1; } elsif (m/^--?wxr$/) { $wxr_p = 1; } elsif (m/^-./) { usage; } elsif (!$app) { $app = $_; } elsif (!$file) { $file = $_; } else { usage; } } usage() unless $app; if ($gen_p) { usage if ($page || $file); return interactive_generate_session_key($app); } usage() unless ($file); retrieve_comments ($app, $page, $file); } main(); exit 0;