├── 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('

' + timeStamp + '

@' + userScreenName + '
' + userRealName + '

' ); 19 | // Set background image or color. 20 | if (data.profile_use_background_image == true) { 21 | $("#" + divID).css('background', 'url(' + data.profile_background_image_url + ') #' + data.profile_background_color); 22 | } 23 | else { 24 | $("#" + divID).css('background', '#' + data.profile_background_color); 25 | } 26 | // Set link color. 27 | $("#" + divID + " a").css('color', '#' + data.profile_link_color); 28 | }); 29 | }); 30 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **@alexwlchan:** I made some changes to this code, but I haven't touched it in over two years and I haven't used it for almost as long. 2 | 3 | You can see my current approach in this Jekyll plugin: . 4 | 5 | It takes a different approach: 6 | 7 | * Download the complete Twitter API response (plus profile image) as a JSON file. 8 | This gets saved in the `_twitter` directory in my Jekyll site. 9 | 10 | * When I want to display a tweet, it reads this JSON file and renders an HTML template. 11 | 12 | This is a bit more flexible: rather than hard-coding HTML in my posts, there's a single template which is re-rendered every time, so I can easily change the style of old tweets. 13 | Having the complete API response gives me more flexibility to do so. 14 | 15 | --- 16 | 17 | Blackbirdpy is a set of scripts and styles for quickly embedding "live" tweets in blog posts or other web articles. All links within the tweet, including URLS, screen names, and hashtags are fully clickable. It accesses the Twitter API, but does no user tracking, cookie planting, or other sketchy business. 18 | 19 | There are three parts to blackbirdpy: 20 | 21 | 1. A Python script that takes a tweet's URL as its command-line argument and returns a chunk of HTML for embedding. The HTML puts the tweet in a `
` 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 |
Embedded 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 CDT
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.py 26 | # 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 | 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 += '

' +\ 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 | --------------------------------------------------------------------------------