├── .gitignore ├── conf_sample.py ├── phantom └── rasterize.js ├── README.md └── PinboardEvernoteSync.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | conf.py* 3 | -------------------------------------------------------------------------------- /conf_sample.py: -------------------------------------------------------------------------------- 1 | # Copy this file to conf.py and replace values 2 | # with the real data to enable unit tests. 3 | 4 | # Get your pinboard token here: https://pinboard.in/settings/password 5 | # ... but somehow it's not working! Please use your username/password instead. #FIXME 6 | # pinboard_token = 'bob:CDE72B0D1090404C2609' 7 | pinboard_username = 'bob' 8 | pinboard_pass = 'mypassword' 9 | # Get your evernote token here: https://www.evernote.com/api/DeveloperToken.action 10 | evernote_token = 'S=s1:U=6154a:E=144bc355fbc:C=13d648433bd:P=1cd:A=en-devtoken:V=2:H=2d23742aaed9c17b9b2adf7bac62d64b' 11 | # If you have a readability parser token (get it here: http://www.readability.com/account/api) 12 | # we will use it to parse a nice excerpt of the page into evernote whenever needed 13 | readability_token = '2c63eb0esds658f6203fc78c4aaaa6b9828a59' -------------------------------------------------------------------------------- /phantom/rasterize.js: -------------------------------------------------------------------------------- 1 | // from https://raw.github.com/ariya/phantomjs/master/examples/rasterize.js 2 | // LICENSED UNDER: https://github.com/ariya/phantomjs/blob/master/LICENSE.BSD 3 | 4 | var page = require('webpage').create(), 5 | system = require('system'), 6 | address, output, size; 7 | 8 | if (system.args.length < 3 || system.args.length > 5) { 9 | console.log('Usage: rasterize.js URL filename [paperwidth*paperheight|paperformat] [zoom]'); 10 | console.log(' paper (pdf output) examples: "5in*7.5in", "10cm*20cm", "A4", "Letter"'); 11 | phantom.exit(1); 12 | } else { 13 | address = system.args[1]; 14 | output = system.args[2]; 15 | page.viewportSize = { width: 800, height: 600 }; 16 | if (system.args.length > 3 && system.args[2].substr(-4) === ".pdf") { 17 | size = system.args[3].split('*'); 18 | page.paperSize = size.length === 2 ? { width: size[0], height: size[1], margin: '0px' } 19 | : { format: system.args[3], orientation: 'portrait', margin: '1cm' }; 20 | } 21 | if (system.args.length > 4) { 22 | page.zoomFactor = system.args[4]; 23 | } 24 | page.open(address, function (status) { 25 | if (status !== 'success') { 26 | console.log('Unable to load the address!'); 27 | phantom.exit(); 28 | } else { 29 | window.setTimeout(function () { 30 | page.render(output); 31 | phantom.exit(); 32 | }, 200); 33 | } 34 | }); 35 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pinboard-Evernote Sync 2 | 3 | ## What is it? 4 | 5 | Sync Pinboard and Evernote bookmarks. 6 | 7 | ## How it works 8 | 9 | The script will: 10 | 11 | 1. retrieve all your bookmarks from pinboard 12 | 2. if there is no entry in Evernote (in any notebook) with the same URL, create one in the notebook "Bookmarks" 13 | * The notebook will be created if you don't have it yet 14 | 3. Add all your pinboard bookmarks into evernote 15 | * If you have lynx installed, the script will save a text dump of the page into Evernote 16 | * If you have phantom.JS installed, the script will also save a screenshot of the page into Evernote 17 | 4. Add all your evernote bookmarks into Pinboard 18 | 19 | ## How to use it 20 | 21 | 1. Get your pinboard API Token here: https://pinboard.in/settings/password 22 | 2. Get an Evernote developer token at: https://www.evernote.com/api/DeveloperToken.action 23 | 3. Create a conf.py file with your credentials (tokens or passwords). The file conf_sample.py will show you how 24 | 4. install https://github.com/evernote/evernote-sdk-python 25 | 5. install https://github.com/mgan59/python-pinboard 26 | 6. [Optional] install lynx, if you want a nice text dump of the pages in your bookmark note 27 | 7. [Optional] get a Readability parser key if you want to use it instead of lynx 28 | 8. [Optional] install phantomJS from http://phantomjs.org/ if you want to keep a screenshot of each bookmarked site in evernote 29 | 9. Run PinboardEvernoteSync.py whenever you feel like syncing your accounts 30 | 10. goto 9 31 | 32 | ## TODO 33 | 34 | * Sync tags 35 | * Add an option to choose the Evernote notebook. Currently only uses the "Bookmarks" default 36 | -------------------------------------------------------------------------------- /PinboardEvernoteSync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """PinboardEvernoteSync.py 5 | 6 | Pinboard is great. Evernote too. 7 | 8 | Make love, not war, use both and keep them in sync. 9 | 10 | """ 11 | 12 | __version__ = "1.0" 13 | __license__ = "WTFPL" 14 | __copyright__ = "Copyright 2013-2015, Olivier Thereaux" 15 | __author__ = "Olivier Thereaux " 16 | 17 | import os 18 | import socket 19 | import subprocess 20 | import re 21 | import sys 22 | import pinboard 23 | import time 24 | from datetime import datetime, date 25 | import hashlib 26 | import binascii 27 | import cgi 28 | import urllib 29 | import urllib2 30 | import json 31 | import hashlib 32 | import binascii 33 | import evernote.edam.userstore.constants as UserStoreConstants 34 | import evernote.edam.limits.constants as EvernoteLimits 35 | from evernote.edam.notestore import NoteStore 36 | import evernote.edam.type.ttypes as Types 37 | import evernote.edam.error.ttypes as Errors 38 | from evernote.api.client import EvernoteClient 39 | 40 | 41 | # initialise clients with tokens 42 | pinboard_username = None 43 | pinboard_pass = None 44 | evernote_token = None 45 | readability_token = None 46 | from conf import * 47 | 48 | class Usage(Exception): 49 | def __init__(self, msg): 50 | self.msg = msg 51 | 52 | 53 | def canhaslynx(): 54 | # test whether lynx is installed 55 | # strongly inspired by http://stackoverflow.com/questions/377017/test-if-executable-exists-in-python 56 | lynx_exe = None 57 | for path in os.environ["PATH"].split(os.pathsep): 58 | path = path.strip('"') 59 | exe_file = os.path.join(path, "lynx") 60 | if os.path.isfile(exe_file) and os.access(exe_file, os.X_OK): 61 | lynx_exe = exe_file 62 | return lynx_exe 63 | 64 | def canhasphantom(): 65 | # test whether phantomJS is installed 66 | phantom_exe = None 67 | for path in os.environ["PATH"].split(os.pathsep): 68 | path = path.strip('"') 69 | exe_file = os.path.join(path, "phantomjs") 70 | if os.path.isfile(exe_file) and os.access(exe_file, os.X_OK): 71 | phantom_exe = exe_file 72 | return phantom_exe 73 | 74 | def clean_href(href): 75 | clean_href = href 76 | try: #python 3.3 and above 77 | from shlex import quote 78 | clean_href = quote(clean_href) 79 | except: 80 | _find_unsafe = re.compile(r'[^\w@%+=:,./-]').search 81 | if _find_unsafe(clean_href): 82 | clean_href = "'" + clean_href.replace("'", "'\"'\"'") + "'" 83 | return clean_href 84 | 85 | def savescreenshotfromphantom(phantom_exe, href): 86 | href = clean_href(href) 87 | phantom_args = [phantom_exe, "phantom/rasterize.js", href, "screenshot.png"] 88 | try: 89 | subprocess.check_output(phantom_args, stderr=open('exceptions.txt', 'w')) 90 | except: 91 | pass 92 | return None 93 | 94 | def getsummaryfromreadability(href, readability_token): 95 | readability_query = 'https://readability.com/api/content/v1/parser?url='+href+'&token='+readability_token 96 | readable_text = urllib.urlopen(readability_query).read() 97 | try: 98 | return '

'+cgi.escape(json.loads(readable_text)['excerpt']).encode("utf-8")+'

' 99 | except: 100 | return '' 101 | 102 | def getsummaryfromlynx(lynx_exe, href): 103 | href = clean_href(href) 104 | lynx_args = [lynx_exe,'-dump','-display_charset=utf-8','-assume_charset=utf-8','-nomargins','-hiddenlinks=ignore','-nonumbers',href] 105 | try: 106 | lynx_dump = cgi.escape(subprocess.check_output(lynx_args, stderr=open('/dev/null', 'w'))) 107 | return '
'+lynx_dump+'
' 108 | except: 109 | return '' 110 | 111 | def save2evernote(pinbookmark, note_store, bookmark_guid, lynx_exe, phantom_exe, readability_token): 112 | note = Types.Note() 113 | # ourNote.notebookGuid = parentNotebook.guid 114 | cleantitle = cgi.escape(pinbookmark["description"]) 115 | # According to https://dev.evernote.com/doc/reference/Types.html#Struct_Note 116 | # The subject of the note. Can't begin or end with a space. Doing some zealous replacement which seems to work 117 | cleantitle = cleantitle.replace(u"\xa0", u" ").encode("utf-8") 118 | cleantitle = re.sub(r"\s+", " ", cleantitle) 119 | cleantitle = re.sub(r"\t+", " ", cleantitle) 120 | cleantitle = re.sub(r"\s*$", "", cleantitle) 121 | cleantitle = re.sub(r"^\s*", "", cleantitle) 122 | # as per https://dev.evernote.com/doc/reference/Limits.html 123 | re.sub(r"[^\p{Cc}\p{Z}]([^\p{Cc}\p{Zl}\p{Zp}]{0,253}[^\p{Cc}\p{Z}])?$", "", cleantitle) 124 | if len(cleantitle) >= EvernoteLimits.EDAM_NOTE_TITLE_LEN_MAX: 125 | cleantitle = cleantitle[:250]+"..." 126 | print "Note title: %s" % cleantitle 127 | note.title = cleantitle 128 | attributes = Types.NoteAttributes(sourceURL = pinbookmark["href"]) 129 | note.attributes = attributes 130 | note.notebookGuid = bookmark_guid 131 | page_dump = '' 132 | if lynx_exe: 133 | # print "Can Has Lynx" 134 | page_dump = getsummaryfromlynx(lynx_exe, pinbookmark["href"]) 135 | if page_dump == "": #lynx failed, or no present. Try readability 136 | # print "Lynx no worky" 137 | if readability_token != None: 138 | # print "Using Readability instead" 139 | page_dump = getsummaryfromreadability(pinbookmark["href"], readability_token) 140 | resource_embed = "" 141 | if phantom_exe: 142 | savescreenshotfromphantom(phantom_exe, pinbookmark['href']) 143 | try: 144 | image = open('screenshot.png', 'rb').read() 145 | except: 146 | image = None 147 | if image: 148 | md5 = hashlib.md5() 149 | md5.update(image) 150 | hash = md5.digest() 151 | 152 | data = Types.Data() 153 | data.size = len(image) 154 | data.bodyHash = hash 155 | data.body = image 156 | 157 | resource = Types.Resource() 158 | resource.mime = 'image/png' 159 | resource.data = data 160 | note.resources = [resource] 161 | hash_hex = binascii.hexlify(hash) 162 | resource_embed = '' 163 | 164 | if resource_embed != "": 165 | os.remove('screenshot.png') 166 | resource_embed += """ 167 |
168 | """ 169 | note.content = ''' 170 | 171 | 172 | %s 173 |
174 | %s 175 | %s 176 |
''' % (cgi.escape(pinbookmark["extended"]).encode("utf-8"), resource_embed, page_dump) 177 | note.created = 1000*int(time.mktime(pinbookmark[u'time_parsed'])) 178 | note.updated = 1000*int(time.mktime(pinbookmark[u'time_parsed'])) 179 | return note_store.createNote(note) 180 | 181 | 182 | def main(): 183 | # Checking if we have Lynx / phantomJS for optional features 184 | #Is Lynx installed? 185 | print "Can we use lynx to extract text?" 186 | lynx_exe = canhaslynx() 187 | if lynx_exe: 188 | print "yes" 189 | else: 190 | print "no" 191 | #Is phantomJS installed? 192 | print "Can we use phantomJS to extract screenshots?" 193 | phantom_exe = canhasphantom() 194 | if phantom_exe: 195 | print "yes" 196 | else: 197 | print "no" 198 | 199 | print "connecting to Evernote…" 200 | client = EvernoteClient(token=evernote_token, sandbox=False) 201 | user_store = client.get_user_store() 202 | try: 203 | note_store = client.get_note_store() 204 | except Errors.EDAMSystemException, e: 205 | if e.errorCode == 19: # rate limit reached 206 | print "Rate limit reached" 207 | print "Retry your request in %d seconds" % e.rateLimitDuration 208 | time.sleep(e.rateLimitDuration+1) 209 | note_store = client.get_note_store() 210 | 211 | # look for notebook "Bookmarks". If there is none, create it 212 | notebooks = note_store.listNotebooks() 213 | bookmark_notebook_guid = None 214 | for notebook in notebooks: 215 | if notebook.name == "Bookmarks": 216 | bookmark_notebook_guid = notebook.guid 217 | if bookmark_notebook_guid == None: 218 | print "Creating Evernote Notebook…" 219 | new_notebook = Types.Notebook() 220 | new_notebook.name = "Bookmarks" 221 | bookmark_notebook_guid = note_store.createNotebook(new_notebook).guid 222 | 223 | # retrieve all ids and URIs in the Evernote notebook 224 | print "Retrieving all Evernote bookmarks…" 225 | filter = NoteStore.NoteFilter(notebookGuid = bookmark_notebook_guid) 226 | spec = NoteStore.NotesMetadataResultSpec() 227 | spec.includeTitle = True 228 | spec.includeAttributes = True 229 | spec.includeCreated = True 230 | note_offset = 0 231 | keep_looking = True 232 | all_evernote_bookmarks = list() 233 | while keep_looking: 234 | found_notes = note_store.findNotesMetadata(filter, note_offset, 250, spec) 235 | # oh FFS Evernote, can you make your API even less user-friendly? 236 | all_evernote_bookmarks += found_notes.notes 237 | if found_notes.totalNotes == len(all_evernote_bookmarks): 238 | keep_looking = False 239 | else: 240 | note_offset = len(all_evernote_bookmarks) 241 | time.sleep(1) 242 | print "Success. Retrieved %d Bookmarks." % len(all_evernote_bookmarks) 243 | all_evernote_bookmarks_map = dict() 244 | all_evernote_uris = list() 245 | for note in all_evernote_bookmarks: 246 | if note.attributes.sourceURL != None: 247 | all_evernote_bookmarks_map[note.attributes.sourceURL] = note 248 | all_evernote_uris.append(note.attributes.sourceURL) 249 | all_evernote_uris.reverse() 250 | 251 | # Now we do the same dance with Pinboard 252 | p = pinboard.open(token=pinboard_token) 253 | print "connecting to Pinboard…" 254 | p = pinboard.open(pinboard_username, pinboard_pass) 255 | print "Retrieving all Pinboard bookmarks…" 256 | all_pinboard_posts = p.posts() 257 | print "Success. Retrieved %d Bookmarks." % len(all_pinboard_posts) 258 | all_pinboard_uris = list() 259 | all_pinboard_posts_map = dict() 260 | for post in all_pinboard_posts: 261 | if post["href"] != None: 262 | all_pinboard_uris.append(post["href"]) 263 | all_pinboard_posts_map[post["href"]] = post 264 | all_pinboard_uris.reverse() 265 | 266 | 267 | # now we compare and prep the sync 268 | missing_from_evernote = list() 269 | missing_from_pinboard = list() 270 | for evernote_uri in all_evernote_uris: 271 | if evernote_uri not in all_pinboard_uris: 272 | missing_from_pinboard.append(evernote_uri) 273 | for pinboard_uri in all_pinboard_uris: 274 | if pinboard_uri not in all_evernote_uris: 275 | missing_from_evernote.append(pinboard_uri) 276 | 277 | if len(missing_from_pinboard): 278 | print "Processing %d bookmarks missing from Pinboard:" % len(missing_from_pinboard) 279 | post_counter = 1 280 | post_counter_total = len(missing_from_pinboard) 281 | for evernote_uri in missing_from_pinboard: 282 | note = all_evernote_bookmarks_map[evernote_uri] 283 | print post_counter, "/", post_counter_total, ": ", evernote_uri 284 | 285 | # remember? p is our pinboard API object 286 | p.add(url=evernote_uri, description=note.title, date = datetime.fromtimestamp(note.created/1000)) 287 | time.sleep(1) 288 | post_counter += 1 289 | print 290 | 291 | if len(missing_from_evernote): 292 | print "Processing %d bookmarks missing from Evernote…" % len(missing_from_evernote) 293 | post_counter = 1 294 | post_counter_total = len(missing_from_evernote) 295 | for pinboard_uri in missing_from_evernote: 296 | post = all_pinboard_posts_map[pinboard_uri] 297 | print post_counter, "/", post_counter_total, ": ", post['href'] 298 | 299 | note_filter = NoteStore.NoteFilter(words='sourceURL:"'+post["href"].encode("utf-8")+'"') 300 | try: 301 | existing_notes = note_store.findNotes(note_filter, 0, 1) 302 | except socket.error: 303 | print "Evernote server timeout, waiting to re-try" 304 | time.sleep(5) 305 | existing_notes = note_store.findNotes(note_filter, 0, 1) 306 | except Errors.EDAMSystemException, e: 307 | if e.errorCode == 19: # limit rate reached 308 | print "Rate limit reached" 309 | print "Retry your request in %d seconds" % e.rateLimitDuration 310 | time.sleep(e.rateLimitDuration+1) 311 | existing_notes = note_store.findNotes(note_filter, 0, 1) 312 | # if this bombs again, let it crash. for now. 313 | if len(existing_notes.notes) > 0: 314 | print "Skipping post: already in Evernote" 315 | pass 316 | else: 317 | try: 318 | created_note = save2evernote(post, note_store, bookmark_notebook_guid, lynx_exe=lynx_exe, phantom_exe=phantom_exe, readability_token=readability_token) 319 | print "Successfully created a new note with GUID: ", created_note.guid 320 | except Exception,e: 321 | print "Could not create a note. Error was: ", e 322 | pass 323 | post_counter += 1 324 | print 325 | 326 | 327 | if __name__ == "__main__": 328 | sys.exit(main()) 329 | --------------------------------------------------------------------------------