├── requirements.in ├── tweet ├── requirements.txt ├── tweet.css ├── styleTweets.js ├── README.md └── blackbirdpy.py /requirements.in: -------------------------------------------------------------------------------- 1 | jinja2 2 | keyring 3 | pytz 4 | requests 5 | tweepy 6 | -------------------------------------------------------------------------------- /tweet: -------------------------------------------------------------------------------- 1 | tell application "Safari" to set tweetURL to URL of front document 2 | do shell script "/usr/bin/python ~/git/blackbirdpy/blackbirdpy.py '" & tweetURL & "'" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile --output-file requirements.txt requirements.in 6 | # 7 | jinja2==2.8 8 | keyring==10.0.2 9 | markupsafe==0.23 # via jinja2 10 | oauthlib==2.0.0 # via requests-oauthlib 11 | pytz==2016.7 12 | requests-oauthlib==0.7.0 # via tweepy 13 | requests==2.11.1 14 | six==1.10.0 # via tweepy 15 | tweepy==3.5.0 16 | -------------------------------------------------------------------------------- /tweet.css: -------------------------------------------------------------------------------- 1 | .bbpBox { 2 | width: 80%; 3 | background: #666; 4 | margin-left: auto; 5 | margin-right: auto; 6 | padding: 1.25em; 7 | } 8 | 9 | .bbpBox blockquote { 10 | font-size: 110%; 11 | background-color: white; 12 | margin: 0em !important; 13 | padding: .5em 1em .5em 1em; 14 | -moz-border-radius: 5px; 15 | -webkit-border-radius: 5px 16 | } 17 | 18 | .bbpBox blockquote a { 19 | color: blue; 20 | text-decoration: none; 21 | } 22 | 23 | .bbpBox blockquote a:hover { 24 | text-decoration: underline; 25 | } 26 | 27 | .bbpBox blockquote .twMeta { 28 | font-size: 80%; 29 | } 30 | 31 | .bbpBox blockquote .twAuthor { 32 | font-family: Sans-serif; 33 | font-size: 80%; 34 | color: #aaa; 35 | margin-top: 0em; 36 | padding-top: .75em; 37 | border-top: solid 1px #aaa; 38 | } 39 | 40 | .bbpBox blockquote .twDate { 41 | font-family: Sans-serif; 42 | font-size: 60%; 43 | text-align: right; 44 | } 45 | 46 | .bbpBox blockquote .twAuthor img { 47 | float: left; 48 | margin-right: .5em; 49 | height: 2.5em; 50 | } 51 | -------------------------------------------------------------------------------- /styleTweets.js: -------------------------------------------------------------------------------- 1 | // This script requires jQuery. 2 | $(document).ready(function() { 3 | styleTweets(); 4 | }); 5 | 6 | function styleTweets() { 7 | $(".bbpBox").each( function(i) { 8 | var divID = $(this).attr("id"); 9 | var tweetID = divID.slice(1); 10 | var timeStamp = $(this).find('span.twTimeStamp').text(); 11 | var userRealName = $(this).find('span.twRealName').text(); 12 | var userScreenName = $(this).find('span.twScreenName').text().slice(1); 13 | var userURL = 'http://api.twitter.com/1/users/show.json?callback=?&screen_name=' + userScreenName + '&include_entities=true'; 14 | $.getJSON(userURL, function(data){ 15 | // Remove plain attribution. 16 | $("#" + divID + " .twMeta").css('display', 'none'); 17 | // Add styled attribution. 18 | content = $("#" + divID + " .twContent").append('
@' + userScreenName + '
' + userRealName + '
` structure and is intended to look decent in an RSS reader. 22 | 2. A CSS file that styles the elements of the embedded tweet. 23 | 3. An AppleScript that gets the URL of the frontmost Safari window and passes it to the Python script, returning the HTML chunk. I use this in a TextExpander snippet, so I can simply type `;tweet` in my text editor and insert the HTML chunk into the article I'm writing. 24 | 25 | The idea is to provide an embedded tweet that looks like a tweet (without the follow, favorite, retweet, etc. buttons) and which degrades to a simple quotation when viewed in RSS. 26 | 27 | Here's an example, which sort of matches what you'd see in an RSS reader: 28 | 29 |30 | 31 | If you follow the link in the tweet, you'll see several tweets. 32 | 33 | Blackbirdpy was forked from [Jeff Miller's project][1], , which was, in turn, inspired by [Robin Sloan's Blackbird Pie][2], a JavaScript tool for embedding tweets that Twitter seems to have removed in favor of a more complicated embedding code that I don't like the look of. 34 | 35 | 36 | [1]: http://twitter.com/jmillerinc/blackbirdpy 37 | [2]: http://techcrunch.com/2010/05/04/twitter-blackbird-pie/ 38 | -------------------------------------------------------------------------------- /blackbirdpy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Blackbirdpy - a Python implementation of Blackbird Pie, the tool 4 | # @robinsloan uses to generate embedded HTML tweets for blog posts. 5 | # 6 | # See: http://media.twitter.com/blackbird-pie 7 | # 8 | # This Python version was written by Jeff Miller, http://twitter.com/jeffmiller 9 | # 10 | # Various formatting changes by Dr. Drang, http://twitter.com/drdrang 11 | # 12 | # Additional changes by Alex Chan, http://twitter.com/alexwlchan 13 | # 14 | # Requires Python 2.6. 15 | # 16 | # Usage: 17 | # 18 | # - To generate embedded HTML for a tweet from inside a Python program: 19 | # 20 | # import blackbirdpy 21 | # embed_html = blackbirdpy.embed_tweet_html(tweet_url) 22 | # 23 | # - To generate embedded HTML for a tweet from the command line: 24 | # 25 | # $ python blackbirdpy.pyEmbedded tweets don’t have to be fragile or track cookies: leancrew.com/all-this/2012/…
— Dr. Drang (@drdrang) Thu Jul 12 2012 11:34 PM CDT26 | # e.g. 27 | # $ python blackbirdpy.py http://twitter.com/punchfork/status/16342628623 28 | # 29 | 30 | import filecmp 31 | import itertools 32 | import os 33 | import re 34 | import shutil 35 | import sys 36 | import tempfile 37 | 38 | from jinja2 import Template 39 | import keyring 40 | import pytz 41 | import requests 42 | import tweepy 43 | 44 | myTZ = pytz.timezone('GB') 45 | 46 | IMAGE_DIR = os.path.join( 47 | os.environ['HOME'], 'Developer', 'alexwlchan.net', 'content', 'images' 48 | ) 49 | 50 | TWEET_EMBED_HTML = Template(""" 51 | 52 | 53 | 64 | 65 | 66 | 71 | 72 | 73 |83 | 84 | """.strip()) 85 | 86 | u''' 87 | ''' 88 | 89 | 90 | def setup_api(): 91 | """ 92 | Authorise the use of the Twitter API. This requires the appropriate 93 | tokens to be set in the system keychain (using the keyring module). 94 | """ 95 | a = { 96 | attr: keyring.get_password('twitter', attr) for attr in [ 97 | 'consumerKey', 98 | 'consumerSecret', 99 | 'token', 100 | 'tokenSecret' 101 | ] 102 | } 103 | if None in a.values(): 104 | raise EnvironmentError("Missing Twitter API keys in keychain.") 105 | auth = tweepy.OAuthHandler(consumer_key=a['consumerKey'], 106 | consumer_secret=a['consumerSecret']) 107 | auth.set_access_token(key=a['token'], secret=a['tokenSecret']) 108 | return tweepy.API(auth) 109 | 110 | 111 | def wrap_entities(t): 112 | """Turn URLs and @ mentions into links. Embed Twitter native photos.""" 113 | text = t.text 114 | mentions = t.entities['user_mentions'] 115 | hashtags = t.entities['hashtags'] 116 | urls = t.entities['urls'] 117 | # media = json['entities']['media'] 118 | try: 119 | media = t.extended_entities['media'] 120 | except (KeyError, AttributeError): 121 | media = [] 122 | 123 | for u in urls: 124 | try: 125 | link = '' + u['display_url'] + '' 126 | except (KeyError, TypeError): 127 | link = '' + u['url'] + '' 128 | text = text.replace(u['url'], link) 129 | 130 | for m in mentions: 131 | text = re.sub('(?i)@' + m['screen_name'], '@' + m['screen_name'] + '', text, 0) 133 | 134 | for h in hashtags: 135 | text = re.sub('(?i)#' + h['text'], '#' + h['text'] + '', text, 0) 137 | 138 | # For some reason, multiple photos have only one URL in the text of the tweet. 139 | if len(media) > 0: 140 | photolink = '' 141 | for m in media: 142 | if m['type'] == 'photo': 143 | photolink += '74 | 79 |82 |{{tweet_text}}
80 | 81 |
' +\ 144 | '' 145 | else: 146 | photolink += '' +\ 147 | m['display_url'] + '' 148 | text = text.replace(m['url'], photolink) 149 | 150 | return text 151 | 152 | def tweet_id_from_tweet_url(tweet_url): 153 | """Extract and return the numeric tweet ID from a full tweet URL.""" 154 | match = re.match(r'^https?://twitter\.com/(?:#!\/)?\w+/status(?:es)?/(\d+)$', tweet_url) 155 | try: 156 | return match.group(1) 157 | except AttributeError: 158 | raise ValueError('Invalid tweet URL: {0}'.format(tweet_url)) 159 | 160 | 161 | def download_file(url): 162 | """Download a file. http://stackoverflow.com/a/16696317/1558022""" 163 | _, local_filename = tempfile.mkstemp() 164 | # NOTE the stream=True parameter 165 | r = requests.get(url, stream=True) 166 | with open(local_filename, 'wb') as f: 167 | for chunk in r.iter_content(chunk_size=1024): 168 | if chunk: # filter out keep-alive new chunks 169 | f.write(chunk) 170 | #f.flush() commented by recommendation from J.F.Sebastian 171 | return local_filename 172 | 173 | 174 | def candidate_filenames(handle): 175 | yield os.path.join(IMAGE_DIR, 'twavatar_%s.jpeg' % handle) 176 | for i in itertools.count(start=1): 177 | yield os.path.join(IMAGE_DIR, 'twavatar_%s_%d.jpeg' % (handle, i)) 178 | 179 | 180 | def cache_avatar(profile_image_url, handle): 181 | """Cache the avatar locally.""" 182 | avatar_url = profile_image_url.replace('_normal', '_bigger') 183 | local_download = download_file(avatar_url) 184 | for filename in candidate_filenames(handle): 185 | if not os.path.exists(filename): 186 | shutil.move(local_download, filename) 187 | return os.path.basename(filename) 188 | elif filecmp.cmp(local_download, filename): 189 | return os.path.basename(filename) 190 | 191 | 192 | def embed_tweet_html(tweet_url): 193 | """Generate embedded HTML for a tweet, given its Twitter URL. The 194 | result is formatted as a simple quote, but with span classes that 195 | allow it to be reformatted dynamically (through jQuery) in the style 196 | of Robin Sloan's Blackbird Pie. 197 | See: http://media.twitter.com/blackbird-pie 198 | """ 199 | tweet_id = tweet_id_from_tweet_url(tweet_url) 200 | api = setup_api() 201 | tweet = api.get_status(tweet_id) 202 | tweet_text = wrap_entities(tweet).replace('\n', '
') 203 | 204 | tweet_created_datetime = pytz.utc.localize(tweet.created_at).astimezone(myTZ) 205 | tweet_timestamp = tweet_created_datetime.strftime("%-I:%M %p - %-d %b %Y") 206 | 207 | avatar_path = cache_avatar( 208 | tweet.user.profile_image_url, 209 | handle=tweet.user.screen_name 210 | ) 211 | 212 | return TWEET_EMBED_HTML.render( 213 | tweet=tweet, 214 | tweet_url=tweet_url, 215 | user=tweet.user, 216 | tweet_text=tweet_text, 217 | source=tweet.source, 218 | profile_pic=avatar_path, 219 | profile_background_color=tweet.user.profile_background_color.lower(), 220 | profileTextColor=tweet.user.profile_text_color.lower(), 221 | profile_link_color=tweet.user.profile_link_color.lower(), 222 | timestamp=tweet_timestamp, 223 | ) 224 | 225 | 226 | if __name__ == '__main__': 227 | print(embed_tweet_html(sys.argv[1])) 228 | --------------------------------------------------------------------------------