Last active
last yearFebruary 16, 2023 07:23
Sync Safari Reading List bookmarks to Pinboard
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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