Skip to content

Instantly share code, notes, and snippets.

@samuelkordik
Last active February 16, 2023 07:23
Sync Safari Reading List bookmarks to Pinboard
#!/Users/samuelkordik/.pyenv/shims/python
# ReadingListCatcher
# - A script for exporting Safari Reading List items to Markdown and Pinboard
# Originally by Brett Terpstra 2015, <https://brettterpstra.com/2015/01/06/reading-list-catcher/>
# Modifications by Zach Fine made in 2020 to use the original reading list item in the
# posts to pinboard.
# Updated 2021-06-21 by Samuel Kordik to fix errors due to deprecated API in plistlib,
# changes to Pinboard api and Pinboard python lib; added enhanced logging output
# and error handling to work as a cron job or shell script.
# 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 = 'all' # pb, md or all
from secrets import PINBOARD_API_KEY # This relies on setting this value in a separate file 'secrets.py'
#PINBOARD_API_KEY = 'Username:XXXXXXXXXXXXXXXXXXXX' # https://pinboard.in/settings/password
BOOKMARKS_MARKDOWN_FILE = '~/Documents/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 plist. No need for conversion.
tempDirectory = gettempdir()
bookmarksFileCopy = copy(bookmarksFile, tempDirectory)
def removeTempFile():
try:
os.remove(bookmarksFileCopy)
except FileNotFoundError as e:
pass # For some reason, when run as a cron job this always comes up as an error.
except:
e = sys.exc_info()
sys.stdout.write("Error: %s" % e)
raise
atexit.register(removeTempFile) # Delete the temp file when the script finishes
class _readingList():
def __init__(self, exportType):
self.postedCount = 0
self.exportType = exportType
timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
if self.exportType == 'pb':
print("%s: Exporting bookmarks to Pinboard" % timestamp)
elif self.exportType == 'md':
print("%s: Exporting bookmarks to Markdown file %s" % (timestamp, BOOKMARKS_MARKDOWN_FILE))
else:
print("%s: Exporting bookmarks with exportType %s" % (timestamp, self.exportType))
if self.exportType == 'pb':
import pinboard
self.pb = pinboard.Pinboard(PINBOARD_API_KEY)
with open(bookmarksFileCopy, 'rb') as fp:
plist = plistlib.load(fp)
# 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]
# Determine last synced bookmark
if self.exportType == 'pb':
# For Pinboard, use the last synced bookmark OR start from 2013-01-01 if no synced bookmarks
lastRLBookmark = self.pb.posts.recent(tag='.readinglist', count=1)
if len(lastRLBookmark['posts']) > 0:
last = lastRLBookmark['date']
else:
last = datetime.strptime("2013-01-01 00:00:00 UTC", '%Y-%m-%d %H:%M:%S UTC')
else:
# For Markdown, get the markdown file (if it exists) and pull the updated time from the header of that file
# Fall back to 2013-01-01 if not found.
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()
else:
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()
# Process new bookmarks in Reading List
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')
#print(item['URIDictionary']['title'])
title = re.sub(cleanRx, ' ', item['URIDictionary']['title'])
title = re.sub(' +', ' ', title)
url = item['URLString']
description = ''
if 'PreviewText' in item['ReadingList']:
description = item['ReadingList']['PreviewText']
description = re.sub(cleanRx, ' ', description)
description = re.sub(' +', ' ', description)
if self.exportType == 'md':
self.itemToMarkdown(addtime, title, url, description)
else:
if not title.strip():
title = 'no title'
post_time=pytz.utc.localize(item['ReadingList']['DateAdded'])
self.itemToPinboard(post_time, title, url, description)
else:
break
# Write output logging information
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')
else:
sys.stdout.write('No new bookmarks found in Reading List')
else:
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)
mdHandle.close()
if self.postedCount > 0:
sys.stdout.write('Added ' + str(self.postedCount) + ' new ' + pluralized + ' to ' + markdownFile)
else:
sys.stdout.write('No new bookmarks found in Reading List')
sys.stdout.write("\n")
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, post_time, title, url, description):
try:
suggestions = self.pb.posts.suggest(url=url)
tags = suggestions[0]['popular']
tags.append('.readinglist')
pinboard_add_status = self.pb.posts.add(url=url, description=title, \
extended=description, tags=tags, shared=False, \
toread=True)
# Error checking for pinboard api
assert(pinboard_add_status), "Failed to save %s" %title
sys.stdout.write("Added %s to Pinboard: %s\n" % (title, pinboard_add_status))
self.postedCount += 1
except:
e = sys.exc_info()
sys.stdout.write("Error: %s" % e)
raise
if __name__ == "__main__":
exportTypes = []
if len(sys.argv) > 1: # Additional arguments provided
for arg in sys.argv:
if re.match("^(md|pb|all)$",arg) and exportTypes.count(arg) == 0:
exportTypes.append(arg)
else:
exportTypes.append(DEFAULT_EXPORT_TYPE)
for eType in exportTypes:
if eType == 'all':
_readingList('pb')
_readingList('md')
else:
_readingList(eType)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment