I’ve heard from a few people that this script might be useful to them. I’ve also created an “app” version of it you can run, but it still takes a wee bit of command line setup. It’s pretty simple, though, and I’ll cover that in a minute.

What the script does is parse your Safari Reading List bookmarks directly from the PLIST file that Safari stores in your support folder. Safari doesn’t technically need to be open to do this, but it will only sync your latest bookmarks from other devices when it’s running. The app version of this script will launch Safari automatically, but you may want to add a command to any launchd setups directly using the script version to do so.

The date of each run of the script is recorded and only newer bookmarks are pulled the next time. If posting to Pinboard, bookmarks are marked with ‘toread’ as well as a ‘.readinglist’ tag (a private tag you can use for sorting and cleanup).


First, regardless of which version you run, you need to install the Python “pinboard” library, which can be done with:

easy_install pinboard

or pip install pinboard if you use pip. I don’t recall my setup having any trouble with permissions, but if you get an error, try using sudo easy_install pinboard (or sudo pip install...).


Then there are just a couple of config settings that are currently hardcoded in the script/workflow. To edit the script, just open it in a text editor, to edit the workflow, right click and Open In… Automator.

Look for the block of text containing:

BOOKMARKS_MARKDOWN_FILE = '~/Dropbox/Reading List Bookmarks.markdown'
BOOKMARKS_PLIST = '~/Library/Safari/Bookmarks.plist'
  • DEFAULT_EXPORT_TYPE can be set to “pb” (Pinboard), “md” (Markdown list), or “all” (exports both)
  • PINBOARD_API_KEY is your full Pinboard API Key.
  • BOOKMARKS_MARKDOWN_FILE can be any path (including filename) for an existing Markdown file
  • You shouldn’t need to modify BOOKMARKS_PLIST

That’s it.


Here’s the script version, and you can grab the Automator Workflow below. Note that this is the first time I’ve ever published a Python script and I’m still feeling my way around building CLIs with it. I’m open to your thoughtful criticism.

# ReadingListCatcher
# - A script for exporting Safari Reading List items to Markdown and Pinboard
#   Brett Terpstra 2015
# Uses code from <https://gist.github.com/robmathers/5995026>
# Requires Python pinboard lib for Pinboard.in import:
#     `easy_install pinboard` or `pip install pinboard`
import plistlib
from shutil import copy
import subprocess
import os
from tempfile import gettempdir
import sys
import atexit
import re
import time
from datetime import date, datetime, timedelta
from os import path
import pytz

DEFAULT_EXPORT_TYPE = 'pb' # pb, md or all
PINBOARD_API_KEY = 'XXXXXXX:XXXXXXXXXXXXXXXXXXXX' # https://pinboard.in/settings/password
BOOKMARKS_MARKDOWN_FILE = '~/Dropbox/Reading List Bookmarks.markdown' # Markdown file if using md export
BOOKMARKS_PLIST = '~/Library/Safari/Bookmarks.plist' # Shouldn't need to modify

bookmarksFile = os.path.expanduser(BOOKMARKS_PLIST)
markdownFile = os.path.expanduser(BOOKMARKS_MARKDOWN_FILE)

# Make a copy of the bookmarks and convert it from a binary plist to text
tempDirectory = gettempdir()
copy(bookmarksFile, tempDirectory)
bookmarksFileCopy = os.path.join(tempDirectory, os.path.basename(bookmarksFile))

def removeTempFile():

atexit.register(removeTempFile) # Delete the temp file when the script finishes

class _readingList():
    def __init__(self, exportType):

        self.postedCount = 0
        self.exportType = exportType

        if self.exportType == 'pb':
            import pinboard
            self.pb = pinboard.Pinboard(PINBOARD_API_KEY)

        converted = subprocess.call(['plutil', '-convert', 'xml1', bookmarksFileCopy])

        if converted != 0:
            print 'Couldn\'t convert bookmarks plist from xml format'

        plist = plistlib.readPlist(bookmarksFileCopy)
         # There should only be one Reading List item, so take the first one
        readingList = [item for item in plist['Children'] if 'Title' in item and item['Title'] == 'com.apple.ReadingList'][0]

        if self.exportType == 'pb':
            lastRLBookmark = self.pb.posts.recent(tag='.readinglist', count=1)
            last = lastRLBookmark['date']
            self.content = ''
            self.newcontent = ''
            # last = time.strptime((datetime.now() - timedelta(days = 1)).strftime('%c'))
            last = time.strptime("2013-01-01 00:00:00 UTC", '%Y-%m-%d %H:%M:%S UTC')

            if not os.path.exists(markdownFile):
                open(markdownFile, 'a').close()
                with open (markdownFile, 'r') as mdInput:
                    self.content = mdInput.read()
                    matchLast = re.search(re.compile('(?m)^Updated: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC)'), self.content)
                    if matchLast != None:
                        last = time.strptime(matchLast.group(1), '%Y-%m-%d %H:%M:%S UTC')

            last = datetime(*last[:6])

            rx = re.compile("(?m)^Updated: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) UTC")
            self.content = re.sub(rx,'',self.content).strip()

        if 'Children' in readingList:
            cleanRx = re.compile("[\|\`\:_\*\n]")
            for item in readingList['Children']:
                if item['ReadingList']['DateAdded'] > last:
                    addtime = pytz.utc.localize(item['ReadingList']['DateAdded']).strftime('%c')
                    title = re.sub(cleanRx, ' ', item['URIDictionary']['title'].encode('utf8'))
                    title = re.sub(' +', ' ', title)
                    url = item['URLString']
                    description = ''

                    if 'PreviewText' in item['ReadingList']:
                        description = item['ReadingList']['PreviewText'].encode('utf8')
                        description = re.sub(cleanRx, ' ', description)
                        description = re.sub(' +', ' ', description)

                    if self.exportType == 'md':
                        self.itemToMarkdown(addtime, title, url, description)
                        self.itemToPinboard(title, url, description)

        pluralized = 'bookmarks' if self.postedCount > 1 else 'bookmark'
        if self.exportType == 'pb':
            if self.postedCount > 0:
                sys.stdout.write('Added ' + str(self.postedCount) + ' new ' + pluralized + ' to Pinboard')
                sys.stdout.write('No new bookmarks found in Reading List')
            mdHandle = open(markdownFile, 'w')
            mdHandle.write('Updated: ' + datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + " UTC\n\n")
            mdHandle.write(self.newcontent + self.content)
            if self.postedCount > 0:
                sys.stdout.write('Added ' + str(self.postedCount) + ' new ' + pluralized + ' to ' + markdownFile)
                sys.stdout.write('No new bookmarks found in Reading List')


    def itemToMarkdown(self, addtime, title, url, description):
        self.newcontent += '- [' + title + '](' + url + ' "Added on ' + addtime + '")'
        if not description == '':
            self.newcontent += "\n\n    > " + description
        self.newcontent += "\n\n"
        self.postedCount += 1

    def itemToPinboard(self, title, url, description):
        suggestions = self.pb.posts.suggest(url=url)
        tags = suggestions[0]['popular']

        self.pb.posts.add(url=url, description=title, \
                extended=description, tags=tags, shared=False, \
        self.postedCount += 1

if __name__ == "__main__":
    exportTypes = []
    if len(sys.argv):
        for arg in sys.argv:
            if re.match("^(md|pb|all)$",arg) and exportTypes.count(arg) == 0:

    for eType in exportTypes:

ReadingListCatcher v1.0.0

A workflow and script for saving Safari Reading List bookmarks to Pinboard and/or Markdown

Published 01/06/15.

Updated 01/06/15. Changelog

DonateMore info…