├── .gitignore
├── LICENSE.txt
├── README.md
├── app.py
├── db.py
├── enote
├── __init__.py
├── test.py
└── util.py
├── environment.yaml
├── github
├── __init__.py
├── test.py
└── util.py
├── images
├── demo-subplots.gif
├── evernote-token.png
├── github-token-created.png
└── github-token.png
├── requirements.txt
├── settings.py
├── setup.py
└── web
├── __init__.py
├── test.py
└── util.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 | MANIFEST
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 | .static_storage/
56 | .media/
57 | local_settings.py
58 |
59 | # Flask stuff:
60 | instance/
61 | .webassets-cache
62 |
63 | # Scrapy stuff:
64 | .scrapy
65 |
66 | # Sphinx documentation
67 | docs/_build/
68 |
69 | # PyBuilder
70 | target/
71 |
72 | # Jupyter Notebook
73 | .ipynb_checkpoints
74 |
75 | # pyenv
76 | .python-version
77 |
78 | # celery beat schedule file
79 | celerybeat-schedule
80 |
81 | # SageMath parsed files
82 | *.sage.py
83 |
84 | # Environments
85 | .env
86 | .venv
87 | env/
88 | venv/
89 | ENV/
90 | env.bak/
91 | venv.bak/
92 |
93 | # Spyder project settings
94 | .spyderproject
95 | .spyproject
96 |
97 | # Rope project settings
98 | .ropeproject
99 |
100 | # mkdocs documentation
101 | /site
102 |
103 | # mypy
104 | .mypy_cache/
105 |
106 | # pycharm
107 | .idea
108 |
109 | # credentials
110 | secret.py
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Meng Lee @ b98705001@gmail.com
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Gist-Evernote
2 |
3 | A Python application that sync your Github Gists and save them to your [Evernote](https://evernote.com/) notebook as screenshots. With the app, you can search your work (Jupyter notebooks, Markdown files, Python scripts, etc) all in one place with pretty result. :sunglasses: :notebook:
4 |
5 | ## Demo
6 | A simple use case for search notebooks including `subplot`:
7 |
8 | 
9 |
10 | ## Background
11 | As a heavy Evernote user and a data scientist, I wondered won't it be wonderful to be able to search all my [Jupyter notebooks](http://jupyter.org/) directly inside Evernote. Thanks to the [OCR technology](https://help.evernote.com/hc/en-us/articles/208314518-How-Evernote-makes-text-inside-images-searchable) built by Evernote, this become possible. Without further ado, try it yourself :wink:
12 |
13 | ## Getting Started
14 | This project is written in Python and tested only on macOS currently.
15 | To start synchronizing your gist to Evernote, follow the instruction below to setup the application.
16 |
17 | ### Prerequisites
18 |
19 | #### Python 2.7
20 |
21 | ```bash
22 | python --version
23 | Python 2.7.14 :: Anaconda, Inc.
24 | ```
25 |
26 | This is due to [Evernote API](https://github.com/evernote/evernote-sdk-python)'s limited support of
27 | [Python3](https://github.com/evernote/evernote-sdk-python3), but will try to migrate in near future.
28 |
29 | #### [Chrome browser](https://www.google.com.tw/chrome/browser/desktop/index.html)
30 |
31 | #### [Chrome driver](https://sites.google.com/a/chromium.org/chromedriver/downloads)
32 | Latest version is recommended. After downloading, put the driver under path like `/usr/local/bin`.
33 |
34 | Check whether the driver is in the path:
35 | ```bash
36 | ls /usr/local/bin | grep chromedriver
37 | chromedriver
38 | ```
39 |
40 | Make sure the path where the driver locate is accessible via `$PATH` environment variable:
41 |
42 | ```bash
43 | echo $PATH
44 | ... :/usr/local/bin: ...
45 | ```
46 |
47 | If the path is not included in `$PATH`, you can add the path to `$PATH` temporarily:
48 | ```bash
49 | export PATH=$PATH:/usr/local/bin
50 | ```
51 |
52 | #### brew and dependencies
53 |
54 | ```commandline
55 | brew install portmidi pygobject pkg-config cairo gobject-introspection
56 | ```
57 |
58 | #### path setting
59 |
60 | Make sure to include necessary path in `~/.bash_profile`:
61 |
62 | ```text
63 | export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib:/usr/local/opt/libffi/lib/pkgconfig
64 | ```
65 |
66 | #### [Github Personal Access Token](https://github.com/settings/tokens)
67 | To grant the app to retrieve your gists,
68 | you have to give the app a access token. You can read more about the token [here](https://github.com/blog/1509-personal-api-tokens).
69 |
70 | Allow following permission when creating the token:
71 |
72 | ```commandline
73 | admin:org_hook, gist, notifications, read:gpg_key, read:public_key, read:repo_hook, repo, user
74 | ```
75 |
76 | After creating the token, you should save the token for later usage:
77 |
78 |
79 |
80 |
81 | #### [Evernote Developer Tokens](https://dev.evernote.com/doc/articles/dev_tokens.php)
82 | To grant the app to create/update notes to your Evernote notebooks, create a [Production Token](https://www.evernote.com/api/DeveloperToken.action) and save for later usage.
83 |
84 |
85 |
86 | ### Installation
87 | First clone the repo and get into the root folder:
88 |
89 | ```commandline
90 | git clone https://github.com/leemengtaiwan/gist-evernote.git
91 | cd gist-evernote
92 | ```
93 |
94 | To install all the dependencies, [Conda](https://conda.io/docs/) is recommended:
95 |
96 | ```commandline
97 | conda env create -n gist-evernote -f environment.yaml
98 | source activate gist-evernote
99 | ```
100 |
101 | Or use `pip` instead:
102 |
103 | ```commandline
104 | pip install -r requirements.txt
105 | ```
106 |
107 | ### Setup
108 | Tell the app about your Github and Evernote tokens acquired [previously](#Github Personal Access Token):
109 |
110 | ```commandline
111 | python setup.py
112 | ```
113 |
114 | You will be asked to provided your tokens and the name of notebook you want your gists to be sync. Alternative, you can modify the [settings.py](settings.py) directly:
115 |
116 | ```python
117 | GITHUB_AUTH_TOKEN = ""
118 | EVERNOTE_PROD_TOKEN = ""
119 | EVERNOTE_SANDBOX_TOKEN = ""
120 | NOTEBOOK_TO_SYNC = "gist-evernote"
121 |
122 | ```
123 |
124 | ### Start to sync your gists
125 | Finally, after input our credentials, now we can run the sync app:
126 |
127 | ```commandline
128 | python app.py
129 | ```
130 |
131 | If anything go well, it will start to fetch your gists,
132 | take screenshots of them and save them as new notes in Evernote. Have a coffee before it finish :sunglasses::coffee:
133 |
134 | To reflect modification to synced gists to Evernote, just run the app again and it will try to do its best:
135 |
136 | ```commandline
137 | python app.py
138 | ```
139 |
140 | ## Contributing
141 |
142 | There are still many things left to be improved. Any advice or pull request is highly appreciated.
143 | Notes that the Python Docstrings of this repo follow
144 | [Numpy Style](http://www.sphinx-doc.org/ja/stable/ext/example_numpy.html#example-numpy).
145 |
146 |
147 | ## Authors
148 |
149 | * **Meng Lee** - find me at `b98705001@gmail.com`
150 |
151 | ## License
152 |
153 | This project is licensed under the MIT License -
154 | see the [LICENSE.txt](LICENSE.txt) for details.
155 |
156 | ## Acknowledgements
157 |
158 | * [Github GraphQL API v4](https://developer.github.com/v4/)
159 | * [Evernote Python SDK](https://github.com/evernote/evernote-sdk-python)
160 |
161 | ## Future Work
162 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import os
3 | import time
4 | from multiprocessing import Pool, cpu_count
5 | from selenium import webdriver
6 | from selenium.webdriver.support import expected_conditions as EC
7 | from selenium.webdriver.common.by import By
8 | from selenium.webdriver.support.ui import WebDriverWait
9 | from selenium.common.exceptions import TimeoutException
10 | from enote.util import get_note, get_notebook, get_notebooks, \
11 | create_resource, create_note, create_notebook, update_note
12 | from github.util import get_user_name, get_all_gists
13 | from web.util import fullpage_screenshot, get_gist_hash, create_chrome_driver
14 | from settings import NOTEBOOK_TO_SYNC
15 | from db import get_db
16 |
17 | DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
18 | GIST_BASE_URL = 'https://gist.github.com'
19 |
20 | notebook = None
21 | github_user = get_user_name() # get current login github user for fetching gist content
22 | db = get_db() # database to store synchronization info
23 |
24 |
25 | def app():
26 | start = time.time()
27 | global notebook
28 |
29 | # find notebook to put new notes
30 | notebooks = get_notebooks()
31 | for n in notebooks:
32 | if n.name == NOTEBOOK_TO_SYNC:
33 | notebook = get_notebook(n.guid)
34 |
35 | # create notebook with the specified name if not found
36 | if not notebook:
37 | notebook = create_notebook(NOTEBOOK_TO_SYNC)
38 | print('Using notebook: %s' % notebook.name)
39 |
40 | # initialize, get all available gists
41 | if db.is_empty() or db.is_cold_start():
42 | gists = get_all_gists()
43 | # sync only gists that were pushed after last synchronization
44 | else:
45 | last_sync_date = db.get_last_sync()
46 | print("Find gists that are updated after last sync (UTC): {}".format(last_sync_date))
47 | gists = get_all_gists(after_date=last_sync_date)
48 |
49 | print("Total number of gists to be synchronized: %d" % len(gists))
50 |
51 | # headless mode to reduce overhead and distraction
52 | driver = create_chrome_driver() if gists else None
53 | for gist in gists:
54 | _ = sync_gist(gist, driver=driver)
55 | if driver:
56 | driver.quit()
57 |
58 | # TODO multi-processes + mysql
59 | # setup multiple selenium drivers to speed up if multiple cpu available
60 | # num_processes = min(4, cpu_count() - 1) if cpu_count() > 1 else 1
61 | # print("Number of %d processes being created" % num_processes)
62 | # pool = Pool(num_processes)
63 | #
64 | # notes = pool.map(sync_gist, gists)
65 | #
66 | # pool.terminate()
67 | # pool.close()
68 | # pool.join()
69 |
70 | # sync all gists successfully, set to warm-start mode
71 | if db.is_cold_start():
72 | db.toggle_cold_start()
73 |
74 | print("Synchronization took {:.0f} seconds.".format(time.time() - start))
75 |
76 |
77 | def sync_gist(gist, driver):
78 | """Sync the Github gist to the corresponding Evernote note.
79 |
80 | Create a new Evernote note if there is no corresponding one with the gist.
81 | Overwrite existing note's content if gist has been changed.
82 |
83 | Parameters
84 | ----------
85 | gist : dict
86 | A Gist acquired by Github GraphQL API with format like:
87 | {
88 | 'id': 'gist_id',
89 | 'name': 'gist_name',
90 | 'description': 'description',
91 | 'pushAt': '2018-01-15T00:48:23Z'
92 |
93 | }
94 |
95 | driver : selenium.webdriver
96 | The web driver used to access gist url
97 |
98 | Returns
99 | -------
100 | note : evernote.edam.type.ttpyes.Note
101 | None if no new note created or updated
102 |
103 | """
104 | note_exist = False
105 | gist_url = '/'.join((GIST_BASE_URL, gist['name']))
106 |
107 | # check existing gist hash before fetch if available
108 | prev_hash = db.get_hash_by_id(gist['id'])
109 | note_guid = db.get_note_guid_by_id(gist['id'])
110 | if prev_hash and note_guid:
111 | note_exist = True
112 | cur_hash = get_gist_hash(github_user, gist['name'])
113 | if prev_hash == cur_hash:
114 | print('Gist {} remain the same, ignore.'.format(gist_url))
115 | db.update_gist(gist, note_guid, cur_hash)
116 | return None
117 |
118 |
119 |
120 | driver.get(gist_url)
121 | # wait at most x seconds for Github rendering gist context
122 | delay_seconds = 10
123 | try:
124 | WebDriverWait(driver, delay_seconds).until(EC.presence_of_element_located((By.CLASS_NAME, 'is-render-ready')))
125 | except TimeoutException:
126 | print("Take longer than {} seconds to load page.".format(delay_seconds))
127 |
128 | # get first file name as default note title
129 | gist_title = driver.find_element(By.CLASS_NAME, 'gist-header-title>a').text
130 |
131 | # take screen shot for the gist and save it temporally
132 | image_path = 'images/{}.png'.format(gist['name'])
133 | fullpage_screenshot(driver, image_path)
134 |
135 | # build skeleton for note (including screenshot)
136 | resource, _ = create_resource(image_path)
137 | note_title = gist['description'] if gist['description'] else gist_title
138 | note_body = format_note_body(gist)
139 |
140 | # get hash of raw gist content and save gist info to database
141 | gist_hash = get_gist_hash(github_user, gist['name'])
142 |
143 | # create new note / update existing note
144 | if not note_exist:
145 | note = create_note(note_title, note_body, [resource], parent_notebook=notebook)
146 | db.save_gist(gist, note.guid, gist_hash)
147 | else:
148 | note = get_note(note_guid)
149 | update_note(note, note_title, note_body, note_guid, [resource])
150 | db.update_gist(gist, note_guid, gist_hash)
151 |
152 | os.remove(image_path)
153 | print("Finish creating note for gist {}".format(gist_url))
154 | return note
155 |
156 |
157 | def format_note_body(gist):
158 | """Create the note content that will be shown before attachments.
159 |
160 | Parameters
161 | ----------
162 | gist : dict
163 | Dict that contains all information of the gist
164 |
165 | Returns
166 | -------
167 | note_body : str
168 |
169 | """
170 | blocks = []
171 |
172 | desc = gist['description']
173 | if desc:
174 | blocks.append(desc)
175 |
176 | gist_url = '/'.join((GIST_BASE_URL, gist['name']))
177 | blocks.append('Gist on Github'.format(gist_url))
178 | note_body = '
'.join(blocks)
179 |
180 | return note_body
181 |
182 |
183 | if __name__ == '__main__':
184 | app()
185 |
--------------------------------------------------------------------------------
/db.py:
--------------------------------------------------------------------------------
1 | import os
2 | import json
3 | import fire
4 | from datetime import datetime
5 | DB_FILE = 'db.json'
6 | ENV_FILE = 'env.json'
7 | DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
8 |
9 |
10 | class Database(object):
11 | """Storage class to keep track of gist sync information.
12 |
13 | This class is independent of the actual implementation
14 | of the database, thus make it easier to change in the future.
15 | """
16 |
17 | def __init__(self):
18 | if not os.path.isfile(DB_FILE) or not os.path.isfile(ENV_FILE):
19 | self.info = {"num_gists": 0}
20 | self.env = {
21 | "cold_start": True,
22 | "sync_at": datetime.strftime(datetime(1990, 10, 22), DATE_FORMAT)}
23 | self.sync_info("save")
24 | self.sync_env("save")
25 | return
26 |
27 | # restore db and env from previous execution result
28 | self.sync_info('load')
29 | self.sync_env('load')
30 |
31 | def is_empty(self):
32 | """Indicate whether there is any gist in database.
33 |
34 | Returns
35 | -------
36 | bool
37 |
38 | """
39 | return self.info.get('num_gists', 0) == 0
40 |
41 | def is_cold_start(self):
42 | """Indicate whether it is needed to synchronize all gists.
43 |
44 | Returns
45 | -------
46 | bool
47 |
48 | """
49 | return self.env.get('cold_start', True)
50 |
51 | def get_last_sync(self):
52 | """Return the UTC datetime indicating the last synchronization
53 |
54 | Returns
55 | -------
56 | last_sync_date : datetime.datetime
57 | """
58 | return datetime.strptime(self.env['sync_at'], DATE_FORMAT)
59 |
60 | def toggle_cold_start(self):
61 | """Toggle value of cold_start"""
62 | self.env['cold_start'] = not self.env.get('cold_start', True)
63 | self.sync_env('save')
64 |
65 | def get_hash_by_id(self, gist_id):
66 | """Get hash value of the gist using `gist_id` as key
67 |
68 | Parameters
69 | ----------
70 | gist_id : str
71 | Unique gist identifier called `id` available in Github API
72 | e.g. "MDQ6R2lzdGUzOTNkODgxMjIyODg1ZjU5ZWYwOWExNDExNzE1OWM4"
73 |
74 | Returns
75 | -------
76 | hash : str
77 | "" if no gist can be found in database by `gist_id`
78 | """
79 | return self.info.get(gist_id, {}).get('hash', '')
80 |
81 | def get_note_guid_by_id(self, gist_id):
82 | """Get guid of note related to the gist with `gist_id`
83 |
84 | Parameters
85 | ----------
86 | gist_id : str
87 | Unique gist identifier called `id` available in Github API
88 | e.g. "MDQ6R2lzdGUzOTNkODgxMjIyODg1ZjU5ZWYwOWExNDExNzE1OWM4"
89 |
90 | Returns
91 | -------
92 | guid : str
93 |
94 | """
95 | return self.info.get(gist_id, {}).get('note_guid', '')
96 |
97 |
98 | def save_gist(self, gist, note_guid, hash):
99 | """Save information of a given gist into database.
100 |
101 | Parameters
102 | ----------
103 | gist : dict
104 | A Gist acquired by Github GraphQL API with format like:
105 | {
106 | 'id': 'gist_id',
107 | 'name': 'gist_name',
108 | 'description': 'description',
109 | 'pushedAt': '2018-01-15T00:48:23Z',
110 | }
111 | note_guid : str
112 | hash : str
113 | """
114 | gist['note_guid'] = note_guid
115 | gist['hash'] = hash
116 |
117 | self.info[gist['id']] = gist
118 | self.info['num_gists'] = self.info.get('num_gists', 0) + 1
119 | self.sync_info('save')
120 | self.update_sync_time(gist['pushedAt'])
121 |
122 | def update_gist(self, gist, note_guid, hash):
123 | """Update information of a given gist into database.
124 |
125 | Parameters
126 | ----------
127 | gist : dict
128 | A Gist acquired by Github GraphQL API with format like:
129 | {
130 | 'id': 'gist_id',
131 | 'name': 'gist_name',
132 | 'description': 'description',
133 | 'pushedAt': '2018-01-15T00:48:23Z'
134 | }
135 | note_guid : str
136 | hash : str
137 |
138 | """
139 |
140 | gist['note_guid'] = note_guid
141 | gist['hash'] = hash
142 |
143 | self.info[gist['id']] = gist
144 | self.sync_info('save')
145 | self.update_sync_time(gist['pushedAt'])
146 |
147 | def update_sync_time(self, sync_date):
148 | """Update last synchronization time
149 |
150 | Parameters
151 | ----------
152 | sync_date : str
153 | String indicating valid datetime like '2018-01-15T00:48:23Z',
154 | which defined by global environment `DATE_FORMAT`.
155 |
156 | """
157 | self.env['sync_at'] = sync_date
158 | self.sync_env('save')
159 |
160 | def sync_env(self, mode):
161 | """Synchronize runtime information between current Database obj and permanent storage
162 |
163 | Parameters
164 | ----------
165 | mode : str
166 | Indicating to save or load environment. Valid values: ["save", "load"]
167 |
168 | Returns
169 | -------
170 | bool
171 |
172 | """
173 | if mode == 'save':
174 | with open(ENV_FILE, 'w') as fp:
175 | json.dump(self.env, fp, indent=2)
176 | elif mode == 'load':
177 | with open(ENV_FILE, "r") as fp:
178 | env = json.load(fp)
179 | self.env = env
180 | return True
181 |
182 | def sync_info(self, mode):
183 | """Synchronize gist info between current Database obj and permanent storage
184 |
185 | Parameters
186 | ----------
187 | mode : str
188 | Indicating to save or load environment. Valid values: ["save", "load"]
189 |
190 | Returns
191 | -------
192 | bool
193 |
194 | """
195 | if mode == 'save':
196 | with open(DB_FILE, 'w') as fp:
197 | json.dump(self.info, fp, indent=2)
198 | elif mode == 'load':
199 | with open(DB_FILE, "r") as fp:
200 | info = json.load(fp)
201 | self.info = info
202 | return True
203 |
204 |
205 | def get_db():
206 | """Get a database instance for storing gist information
207 |
208 | Returns
209 | -------
210 | db : Database instance
211 |
212 | """
213 | return Database()
214 |
215 |
216 | if __name__ == '__main__':
217 | fire.Fire()
--------------------------------------------------------------------------------
/enote/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leemengtw/gist-evernote/90d8573870ded37dc82575ba25968d7a06efe219/enote/__init__.py
--------------------------------------------------------------------------------
/enote/test.py:
--------------------------------------------------------------------------------
1 | import time
2 | import sys
3 | import unittest
4 | import util
5 |
6 |
7 | class Test(unittest.TestCase):
8 | """ Test Evernote API"""
9 |
10 | def setUp(self):
11 | pass
12 |
13 | def tearDown(self):
14 | pass
15 |
16 | def test_access(self):
17 | """Test simple access to Evernote API"""
18 | util.simple_access()
19 |
20 | def test_create_note(self):
21 | auth_token = util.get_auth_token()
22 | note_store = util.get_note_store()
23 | util.create_note(auth_token, note_store, 'Test Note', 'Hello world')
24 |
25 | def test_create_note_with_attachments(self):
26 | auth_token = util.get_auth_token()
27 | note_store = util.get_note_store()
28 |
29 | resources = []
30 | resource = util.create_resource('images/test.png')
31 | resources.append(resource)
32 |
33 | util.create_note(auth_token, note_store,
34 | 'Test Note with attachments', 'Hello world', resources)
35 |
36 |
37 | if __name__ == "__main__":
38 | unittest.main(argv=[sys.argv[0]])
39 |
--------------------------------------------------------------------------------
/enote/util.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | from __future__ import print_function
3 | from __future__ import unicode_literals
4 |
5 | import fire
6 | import time
7 | import hashlib
8 | import tzlocal
9 | from datetime import datetime
10 | from evernote.api.client import EvernoteClient
11 | from evernote.edam.type import ttypes
12 | from evernote.edam.error import ttypes as Errors
13 | from evernote.edam.limits.constants import EDAM_NOTE_TITLE_LEN_MAX
14 | from secret import EVERNOTE_PROD_TOKEN, EVERNOTE_SANDBOX_TOKEN
15 |
16 |
17 | def get_evernote_auth_token(env="prod"):
18 | """Return either a valid production / dev Evernote developer token.
19 |
20 | Parameters
21 | ----------
22 | env : str
23 | Indicate which environment's token to be returned.
24 | Valid options: ["prod", "dev"]
25 |
26 | Returns
27 | -------
28 | token : str
29 |
30 | """
31 | return EVERNOTE_PROD_TOKEN if env == 'prod' else EVERNOTE_SANDBOX_TOKEN
32 |
33 |
34 | def get_client(env="prod"):
35 | """Return a Evernote API client
36 |
37 | Parameters
38 | ----------
39 | env : str
40 | Indicate which environment's token to be returned.
41 | Valid options: ["prod", "dev"]
42 |
43 | Returns
44 | -------
45 | client : evernote.api.client.EvernoteClient
46 |
47 | """
48 | sandbox = False if env == 'prod' else True
49 | return EvernoteClient(token=get_evernote_auth_token(env), sandbox=sandbox)
50 |
51 |
52 | def get_note_store(env="prod"):
53 | """Return a NoteStore used to used to manipulate notes, notebooks in a user account.
54 |
55 | Parameters
56 | ----------
57 | env : str
58 | Indicate which environment's token to be returned.
59 | Valid options: ["prod", "dev"]
60 |
61 | Returns
62 | -------
63 | NoteStore
64 |
65 | Notes
66 | -----
67 | Evernote documentation:
68 | https://dev.evernote.com/doc/start/python.php
69 |
70 | """
71 | return get_client(env).get_note_store()
72 |
73 |
74 | def get_note(guid=None, env='prod'):
75 | """Return a specific Note instance by guid.
76 |
77 | Parameters
78 | ----------
79 | guid : str
80 |
81 | env : str
82 | Indicate which environment's token to be returned.
83 | Valid options: ["prod", "dev"]
84 |
85 | Returns
86 | -------
87 | evernote.edam.type.ttypes.Note
88 |
89 | """
90 | auth_token = get_evernote_auth_token(env)
91 |
92 |
93 | assert guid is not None, 'Guid is not available.'
94 | return get_note_store().getNote(auth_token, guid, False, False, False , False)
95 |
96 |
97 | def get_notebook(guid=None):
98 | """Return a specific Notebook instance by guid.
99 |
100 | Parameters
101 | ----------
102 | guid : str
103 |
104 | Returns
105 | -------
106 | evernote.edam.type.ttypes.Notebook
107 |
108 | """
109 | assert guid is not None, 'Guid is not available.'
110 | return get_note_store().getNotebook(guid)
111 |
112 |
113 | def get_notebooks(env="prod"):
114 | """Get all available Notebook instances in a user account.
115 |
116 | Parameters
117 | ----------
118 | env : str
119 | Indicate which environment's token to be returned.
120 | Valid options: ["prod", "dev"]
121 |
122 | Returns
123 | -------
124 | Notebooks : list of evernote.edam.type.ttypes.Notebook
125 |
126 | """
127 | client = get_client(env)
128 |
129 | # get information about current user
130 | userStore = client.get_user_store()
131 | user = userStore.getUser()
132 | print('Current user:', user.username)
133 |
134 | # get information about notes
135 | noteStore = client.get_note_store()
136 | notebooks = noteStore.listNotebooks()
137 | # for n in notebooks:
138 | # print(n.name, n.guid)
139 | return notebooks
140 |
141 |
142 | def create_notebook(name=None):
143 | """Create a new notebook with given `name`.
144 |
145 | Parameters
146 | ----------
147 | name : str
148 | Indicating the name of the notebook to be created
149 |
150 | Returns
151 | -------
152 | evernote.edam.type.ttypes.Notebook
153 |
154 | """
155 | assert name is not None, 'Notebook name is not specified.'
156 | notebook = ttypes.Notebook()
157 | notebook.name = name
158 | return get_note_store().createNotebook(notebook)
159 |
160 |
161 | def create_resource(file_path, mime='image/png'):
162 | """Create a Resource instance for attaching to evernote Note instance
163 |
164 | Parameters
165 | ----------
166 | file_path : str
167 | Indicate file path of the file
168 |
169 | mime : str, optional
170 | Valid MIME type indicating type of the file
171 |
172 | Returns
173 | -------
174 | evernote.edam.type.ttypes.Resource
175 | The Resource must contain three parts:
176 | - MIME type
177 | - content: evernote.edam.type.ttypes.Data
178 | - hash
179 |
180 | Notes
181 | -----
182 | Create string of MD5 sum:
183 | https://stackoverflow.com/questions/5297448/how-to-get-md5-sum-of-a-string-using-python
184 |
185 | """
186 |
187 | file_data = None
188 | with open(file_path, 'rb') as f:
189 | byte_str = f.read()
190 | file_data = bytearray(byte_str)
191 |
192 | md5 = hashlib.md5()
193 | md5.update(file_data)
194 | hexhash = md5.hexdigest()
195 | data = ttypes.Data()
196 |
197 | # build Resource's necessary data
198 | data.size = len(file_data)
199 | data.bodyHash = hexhash
200 | data.body = file_data
201 |
202 | # build Resource Type
203 | resource = ttypes.Resource()
204 | resource.mime = mime
205 | resource.data = data
206 | return resource, hexhash
207 |
208 |
209 | def create_note(note_title, note_body, resources=[], parent_notebook=None, env="prod"):
210 | """Create new Note with the given attachments in user's notebook
211 |
212 | Parameters
213 | ----------
214 | note_title : str
215 | Text to used as new note's title
216 |
217 | note_body : str
218 | Text to insert into note
219 |
220 | resources : list of evernote.edam.type.ttypes.Resource
221 | List of attachments to combined with the note
222 |
223 | parent_notebook : evernote.edam.type.ttypes.Notebook, optional
224 | Notebook instance to insert new note
225 |
226 | env : str
227 | Indicate which environment's token to be returned.
228 | Valid options: ["prod", "dev"]
229 |
230 | Returns
231 | -------
232 | evernote.edam.type.ttypes.Note
233 | The newly created Note instance
234 |
235 | Notes
236 | -----
237 | Evernote error documentation:
238 | http://dev.evernote.com/documentation/reference/Errors.html#Enum_EDAMErrorCode
239 |
240 | Evernote Struct: Note
241 | https://dev.evernote.com/doc/reference/Types.html#Struct_Note
242 |
243 | """
244 | auth_token = get_evernote_auth_token(env)
245 | note_store = get_note_store(env)
246 |
247 | # create note object
248 | new_note = ttypes.Note()
249 |
250 | # get formatted title
251 | new_note.title = build_note_title(note_title)
252 |
253 | # build body of note
254 | new_note.resources = resources
255 | new_note.content = build_note_content(note_body, resources)
256 |
257 | # parent_notebook is optional. if omitted, default notebook is used
258 | if parent_notebook and hasattr(parent_notebook, 'guid'):
259 | new_note.notebookGuid = parent_notebook.guid
260 |
261 | # attempt to create note in Evernote account
262 | try:
263 | note = note_store.createNote(auth_token, new_note)
264 | except Errors.EDAMUserException, edue:
265 | print("EDAMUserException:", edue)
266 | return None
267 | except Errors.EDAMNotFoundException, ednfe:
268 | # Parent Notebook GUID doesn't correspond to an actual notebook
269 | print("EDAMNotFoundException: Invalid parent notebook GUID")
270 | return None
271 |
272 | # Return created note object
273 | return note
274 |
275 |
276 | def update_note(note, note_title, note_body, note_guid, resources):
277 | """Update existing note in Evernote identified by `note_guid`.
278 |
279 | Parameters
280 | ----------
281 | note : evernote.edam.type.ttypes.Note
282 | Note instance to be updated
283 |
284 | note_title : str
285 | Text to used as new note's title
286 |
287 | note_body : str
288 | Text to insert into note
289 |
290 | note_guid : str
291 |
292 | resources : list of evernote.edam.type.ttypes.Resource
293 | List of attachments to combined with the note
294 |
295 | Returns
296 | -------
297 | evernote.edam.type.ttypes.Note
298 | The updated Note instance
299 |
300 | """
301 |
302 | auth_token = get_evernote_auth_token()
303 | note_store = get_note_store()
304 |
305 | note.guid = note_guid
306 | note.title = build_note_title(note_title)
307 |
308 | # build body of note with new resources
309 | note.resources = resources
310 | note.content = build_note_content(note_body, resources)
311 |
312 | # update `updated time` of the note in timezone-aware manner
313 | tz = tzlocal.get_localzone()
314 | dt = tz.localize(datetime.now())
315 | note.updated = time.mktime(dt.timetuple()) * 1e3
316 |
317 | # attempt to update note in Evernote account
318 | try:
319 | note = note_store.updateNote(auth_token, note)
320 | except Errors.EDAMNotFoundException, ednfe:
321 | # Parent Notebook GUID doesn't correspond to an actual notebook
322 | print("EDAMNotFoundException: Invalid parent notebook GUID")
323 | return None
324 |
325 | # Return created note object
326 | return note
327 |
328 |
329 | def build_note_title(note_title):
330 | """Return a formatted title with right encoding.
331 |
332 | The func handle special formatting and encoding for title to avoid Evernote API Error.
333 |
334 | Parameters
335 | ----------
336 | note_title : str / unicode
337 |
338 | Returns
339 | -------
340 | formatted_note_title : str
341 |
342 | Notes
343 | -----
344 | Evernote API documentation
345 | https://dev.evernote.com/doc/reference/NoteStore.html#Fn_NoteStore_updateNote
346 |
347 | """
348 | formatted_note_title = note_title.strip().replace('\n', ' ')
349 | for title_charset in 'US-ASCII', 'ISO-8859-1', 'UTF-8':
350 | try:
351 | formatted_note_title = formatted_note_title.encode(title_charset)
352 | except UnicodeError:
353 | pass
354 | else:
355 | break
356 |
357 | # restrict the size of title in sure to be able to sync to evernote
358 | formatted_note_title = formatted_note_title[:EDAM_NOTE_TITLE_LEN_MAX]
359 |
360 | return formatted_note_title
361 |
362 |
363 | def build_note_content(note_body, resources):
364 | """Return notebook content with attachments written in Evernote Markup Language.
365 |
366 | Parameters
367 | ----------
368 | note_body : str
369 | Custom content to insert before attachments
370 |
371 | resources : list of evernote.edam.type.ttypes.Resource
372 | List of attachments to combined with the note
373 |
374 | Returns
375 | -------
376 | note_content : str
377 |
378 | Notes
379 | -----
380 | Understanding the Evernote Markup Language:
381 | https://dev.evernote.com/doc/articles/enml.php
382 |
383 |
384 | """
385 | note_content = ''
386 | note_content += ''
387 | note_content += "%s" % note_body
388 |
389 | if resources:
390 | # add Resource objects to note content
391 | note_content += "
" * 2
392 |
393 | for resource in resources:
394 | hexhash = resource.data.bodyHash
395 | note_content += "
" % (resource.mime, hexhash)
396 | note_content += ""
397 |
398 | # handling encoding
399 | for title_charset in 'US-ASCII', 'ISO-8859-1', 'UTF-8':
400 | try:
401 | note_content = note_content.encode(title_charset)
402 | except UnicodeError:
403 | pass
404 | else:
405 | break
406 |
407 | return note_content
408 |
409 |
410 | if __name__ == '__main__':
411 | fire.Fire()
--------------------------------------------------------------------------------
/environment.yaml:
--------------------------------------------------------------------------------
1 | name: gist-evernote
2 | channels:
3 | - conda-forge
4 | - defaults
5 | dependencies:
6 | - fire=0.1.2=py_0
7 | - freetype=2.8.1=0
8 | - imageio=2.2.0=py27_0
9 | - jpeg=9b=2
10 | - libpng=1.6.34=0
11 | - libtiff=4.0.9=0
12 | - olefile=0.44=py27_0
13 | - pillow=5.0.0=py27_0
14 | - selenium=3.8.1=py27_0
15 | - setuptools=38.4.0=py27_0
16 | - six=1.11.0=py27_1
17 | - xz=5.2.3=0
18 | - asn1crypto=0.24.0=py27_0
19 | - ca-certificates=2017.08.26=ha1e5d58_0
20 | - certifi=2017.11.5=py27hfa9a1c4_0
21 | - cffi=1.11.4=py27h342bebf_0
22 | - chardet=3.0.4=py27h2842e91_1
23 | - cryptography=2.1.4=py27hdbc5e8f_0
24 | - enum34=1.1.6=py27hf475452_1
25 | - idna=2.6=py27hedea723_1
26 | - intel-openmp=2018.0.0=h8158457_8
27 | - ipaddress=1.0.19=py27_0
28 | - libcxx=4.0.1=h579ed51_0
29 | - libcxxabi=4.0.1=hebd6815_0
30 | - libedit=3.1=hb4e282d_0
31 | - libffi=3.2.1=h475c297_4
32 | - libgfortran=3.0.1=h93005f0_2
33 | - mkl=2018.0.1=hfbd8650_4
34 | - ncurses=6.0=hd04f020_2
35 | - numpy=1.14.0=py27h8a80b8c_0
36 | - openssl=1.0.2n=hdbc3d79_0
37 | - pip=9.0.1=py27h1567d89_4
38 | - pycparser=2.18=py27h0d28d88_1
39 | - pyopenssl=17.5.0=py27hfda213f_0
40 | - pysocks=1.6.7=py27h1cff6a6_1
41 | - python=2.7.14=hde5916a_29
42 | - readline=7.0=hc1231fa_4
43 | - requests=2.18.4=py27h9b2b37c_1
44 | - sqlite=3.20.1=h7e4c145_2
45 | - tk=8.6.7=h35a86e2_3
46 | - urllib3=1.22=py27hc3787e9_0
47 | - wheel=0.30.0=py27h677a027_1
48 | - zlib=1.2.11=hf3cbc9b_2
49 | - pip:
50 | - altgraph==0.15
51 | - better-exceptions==0.2.1
52 | - cycler==0.10.0
53 | - dis3==0.1.2
54 | - evernote==1.25.3
55 | - functools32==3.2.3.post2
56 | - future==0.16.0
57 | - httplib2==0.10.3
58 | - macholib==1.9
59 | - mercurial==4.3.3
60 | - nose==1.3.7
61 | - oauth2==1.9.0.post1
62 | - pefile==2017.11.5
63 | - pygobject
64 | - pyperclip==1.6.0
65 | - python-dateutil==2.6.0
66 | - pytz==2018.3
67 | - subprocess32==3.2.7
68 | - tzlocal==1.5.1
69 | prefix: /Users/leemeng/anaconda3/envs/gist-evernote
70 |
71 |
--------------------------------------------------------------------------------
/github/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leemengtw/gist-evernote/90d8573870ded37dc82575ba25968d7a06efe219/github/__init__.py
--------------------------------------------------------------------------------
/github/test.py:
--------------------------------------------------------------------------------
1 | import time
2 | import sys
3 | import unittest
4 | import util
5 |
6 |
7 | class Test(unittest.TestCase):
8 | """ Test Github GraphQL API"""
9 |
10 | def setUp(self):
11 | pass
12 |
13 | def tearDown(self):
14 | pass
15 |
16 | def test_access(self):
17 | """Test simple access to Github GraphQL API"""
18 | pass
19 |
20 | query = "{\"query\":\"query {\\n viewer {\\n login\\n }\\n}\"}"
21 | print(util.query_graphql(query))
22 |
23 |
24 |
25 | if __name__ == "__main__":
26 | unittest.main(argv=[sys.argv[0]])
27 |
--------------------------------------------------------------------------------
/github/util.py:
--------------------------------------------------------------------------------
1 | import fire
2 | import requests
3 | from datetime import datetime
4 | from secret import GITHUB_AUTH_TOKEN
5 |
6 | GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql'
7 | DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
8 |
9 |
10 | def get_user_name(token=GITHUB_AUTH_TOKEN):
11 | """Return current login user's name with given token
12 |
13 | Parameters
14 | ----------
15 | token : str
16 | String representing Github Developer Access Token
17 |
18 | Returns
19 | -------
20 | user_name : str
21 | """
22 | payload = "{\"query\":\"query {\\n viewer {\\n login\\n }\\n}\"}"
23 | res = query_graphql(payload, token)
24 | return res['data']['viewer']['login']
25 |
26 |
27 | def query_graphql(payload, token=GITHUB_AUTH_TOKEN, url=GITHUB_GRAPHQL_URL):
28 | """Helper to query Github GraphQL API
29 |
30 | Parameters
31 | ----------
32 | payload : str
33 | Valid GraphQL query string.
34 | e.g., "{\"query\":\"query {\\n viewer {\\n login\\n }\\n}\"}"
35 |
36 | Returns
37 | -------
38 | res : dict
39 |
40 | """
41 | headers = {
42 | 'content-type': "application/json",
43 | 'authorization': "Bearer {}".format(token)
44 | }
45 |
46 | res = requests.request("POST", url, data=payload, headers=headers).json()
47 | assert res.get('data', False), 'No data available from Github: {}'.format(res)
48 | return res
49 |
50 |
51 | def get_gists(cursor=None, size=100):
52 | """Return all gists (public & secret) and end_cursor for pagination
53 |
54 | Parameters
55 | ----------
56 | cursor : str
57 | String indicating endCursor in previous API request.
58 | e.g. "Y3Vyc29yOnYyOpK5MjAxOC0wMS0yM1QxMTo1NDo0MSswOTowMM4FGyp6"
59 |
60 | size : int
61 | Specify how many gists to fetch in a HTTP request to Github.
62 | Default set to Node limit specified by Github GraphQL resource limit.
63 |
64 | Returns
65 | -------
66 | gists : list of dict
67 | List of gists with each gist is a dict of form:
68 | {
69 | "id": "id",
70 | "description": "some description",
71 | "name": "just a name",
72 | "pushedAt": "2018-01-15T08:32:57Z"
73 | }
74 |
75 | total : int
76 | Indicate how many gists available
77 |
78 | end_cursor : str
79 | A string representing the endCursor in gists.pageInfo
80 |
81 | has_next_page : bool
82 | Indicating whether there are gists remains
83 |
84 | Notes
85 | -----
86 | Github GraphQL resource limit
87 | https://developer.github.com/v4/guides/resource-limitations/
88 |
89 | """
90 | first_payload = "{\"query\":\"query {viewer {gists(first:%d, privacy:ALL, orderBy: {field: UPDATED_AT, direction: DESC}) {totalCount edges { node { id description name pushedAt } cursor } pageInfo { endCursor hasNextPage } } } }\"}"
91 | payload_template = "{\"query\":\"query {viewer {gists(first:%d, privacy:ALL, orderBy: {field: UPDATED_AT, direction: DESC}, after:\\\"%s\\\") {totalCount edges { node { id description name pushedAt } cursor } pageInfo { endCursor hasNextPage } } } }\"}"
92 |
93 | if not cursor:
94 | payload = first_payload % size
95 | else:
96 | payload = payload_template % (size, cursor)
97 |
98 | res = query_graphql(payload)
99 |
100 |
101 | # parse nested response for easier usage
102 | gists = res['data']['viewer']['gists']
103 | total = gists['totalCount']
104 | page_info = gists['pageInfo']
105 | end_cursor, has_next_page = page_info['endCursor'], page_info['hasNextPage']
106 | gists = [e['node'] for e in gists['edges']]
107 |
108 | return gists, total, end_cursor, has_next_page
109 |
110 |
111 | def get_number_of_gists():
112 | """Get total number of gists available in the user account
113 |
114 | Returns
115 | -------
116 | num_gists : int
117 | """
118 | payload = "{\"query\":\"query { viewer { gists(privacy:ALL) {totalCount}}}\"}"
119 | res = query_graphql(payload)
120 | return res['data']['viewer']['gists']['totalCount']
121 |
122 |
123 | def get_all_gists(size=None, after_date=None, filter_on='pushedAt'):
124 | """Get number of `size` gists at once without pagination.
125 |
126 | A wrapper over `get_gists` func. Handle the pagination automatically.
127 | If size is not set by user, query Github for total number of gists.
128 | If a valid `after_date` is given, gists with field `filter_on` earlier than
129 | `after_date` will be dropped.
130 |
131 | Parameters
132 | ----------
133 | size : int, optional
134 | Number of gists to fetch. Set to total number of gists if not set by user
135 |
136 | after_date : datetime.datetime
137 | UTC date to filter gists
138 |
139 | filter_on : str
140 | Date field corresponding to Github API for Gist
141 |
142 | Returns
143 | -------
144 | gists : list of dict
145 | List of gists with each gist is a dict of form:
146 | {
147 | "id": "id",
148 | "description": "some description",
149 | "name": "just a name",
150 | "pushedAt": "2018-01-15T08:32:57Z"
151 | }
152 |
153 | See Also
154 | --------
155 | get_gists : Return all gists (public & secret) and end_cursor for pagination
156 |
157 |
158 | """
159 | if not size:
160 | size = get_number_of_gists()
161 |
162 | end_cursor = None
163 | gists = []
164 |
165 | while True:
166 | cur_gists, total, end_cursor, has_next_page = get_gists(end_cursor)
167 | for gist in cur_gists:
168 | pushed_date = datetime.strptime(gist[filter_on], DATE_FORMAT)
169 | if after_date and pushed_date <= after_date:
170 | return gists
171 |
172 | if len(gists) >= size:
173 | return gists
174 | gists.append(gist)
175 |
176 | if not has_next_page:
177 | break
178 |
179 | return gists
180 |
181 |
182 | if __name__ == '__main__':
183 | fire.Fire()
184 |
--------------------------------------------------------------------------------
/images/demo-subplots.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leemengtw/gist-evernote/90d8573870ded37dc82575ba25968d7a06efe219/images/demo-subplots.gif
--------------------------------------------------------------------------------
/images/evernote-token.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leemengtw/gist-evernote/90d8573870ded37dc82575ba25968d7a06efe219/images/evernote-token.png
--------------------------------------------------------------------------------
/images/github-token-created.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leemengtw/gist-evernote/90d8573870ded37dc82575ba25968d7a06efe219/images/github-token-created.png
--------------------------------------------------------------------------------
/images/github-token.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leemengtw/gist-evernote/90d8573870ded37dc82575ba25968d7a06efe219/images/github-token.png
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | altgraph==0.15
2 | asn1crypto==0.24.0
3 | better-exceptions==0.2.1
4 | certifi==2017.11.5
5 | cffi==1.11.4
6 | chardet==3.0.4
7 | cryptography==2.1.4
8 | cycler==0.10.0
9 | dis3==0.1.2
10 | enum34==1.1.6
11 | evernote==1.25.3
12 | fire==0.1.2
13 | functools32==3.2.3.post2
14 | future==0.16.0
15 | httplib2==0.10.3
16 | idna==2.6
17 | imageio==2.2.0
18 | ipaddress==1.0.19
19 | macholib==1.9
20 | mercurial==4.3.3
21 | nose==1.3.7
22 | numpy==1.13.3
23 | oauth2==1.9.0.post1
24 | olefile==0.44
25 | pefile==2017.11.5
26 | Pillow==5.0.0
27 | pycparser==2.18
28 | pygobject==3.24.1
29 | pyOpenSSL==17.5.0
30 | pyperclip==1.6.0
31 | pyportmidi==0.0.7
32 | PySocks==1.6.7
33 | python-dateutil==2.6.0
34 | pytz==2018.3
35 | requests==2.18.4
36 | selenium==3.8.1
37 | six==1.11.0
38 | subprocess32==3.2.7
39 | TBB==0.1
40 | tzlocal==1.5.1
41 | urllib3==1.22
42 |
--------------------------------------------------------------------------------
/settings.py:
--------------------------------------------------------------------------------
1 | GITHUB_AUTH_TOKEN = ""
2 | EVERNOTE_PROD_TOKEN = ""
3 | EVERNOTE_SANDBOX_TOKEN = ""
4 | NOTEBOOK_TO_SYNC = "gist-evernote"
5 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | GITHUB_SECRET_FILE = 'github/secret.py'
3 | EVERNOTE_SECRET_FILE = 'enote/secret.py'
4 | SETTING_FILE = 'settings.py'
5 | NOTEBOOK = 'gist-evernote'
6 |
7 | def initialize():
8 | """Use settings.py to setup/update environment"""
9 | from settings import GITHUB_AUTH_TOKEN, \
10 | EVERNOTE_PROD_TOKEN, EVERNOTE_SANDBOX_TOKEN
11 |
12 | setting_str = ''
13 |
14 | # setup github credential
15 | while not GITHUB_AUTH_TOKEN:
16 | GITHUB_AUTH_TOKEN = raw_input("Github Personal Access Token: ")
17 |
18 | with open(GITHUB_SECRET_FILE, 'w') as f:
19 | string = "GITHUB_AUTH_TOKEN = \"{}\"\n".format(GITHUB_AUTH_TOKEN)
20 | f.write(string)
21 | setting_str += string
22 |
23 | # setup evernote credential
24 | while not EVERNOTE_PROD_TOKEN:
25 | EVERNOTE_PROD_TOKEN = raw_input("Evernote Production Developer Token: ")
26 |
27 | with open(EVERNOTE_SECRET_FILE, 'w') as f:
28 | string = "EVERNOTE_PROD_TOKEN = \"{}\"\n".format(EVERNOTE_PROD_TOKEN)
29 | f.write(string)
30 | setting_str += string
31 |
32 | # optional for local debug
33 | string = "EVERNOTE_SANDBOX_TOKEN = \"{}\"\n".format(EVERNOTE_SANDBOX_TOKEN)
34 | f.write(string)
35 | setting_str += string
36 |
37 | # setup notebook
38 | notebook = raw_input("Evernote notebook name you want to put gists (default set to {}): ".format(NOTEBOOK))
39 | notebook = notebook if notebook else NOTEBOOK
40 | setting_str += "NOTEBOOK_TO_SYNC = \"{}\"\n".format(notebook)
41 |
42 | # update setting
43 | with open(SETTING_FILE, "w") as f:
44 | f.write(setting_str)
45 |
46 | print("You're all set! run\n\n python app.py\n\nto start synchronization :) ")
47 |
48 | return True
49 |
50 |
51 | if __name__ == '__main__':
52 | initialize()
53 |
--------------------------------------------------------------------------------
/web/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leemengtw/gist-evernote/90d8573870ded37dc82575ba25968d7a06efe219/web/__init__.py
--------------------------------------------------------------------------------
/web/test.py:
--------------------------------------------------------------------------------
1 | """
2 | This script uses a simplified version of the one here:
3 | https://snipt.net/restrada/python-selenium-workaround-for-full-page-screenshot-using-chromedriver-2x/
4 |
5 | It contains the *crucial* correction added in the comments by Jason Coutu.
6 | """
7 |
8 | import sys
9 | import time
10 |
11 | from selenium import webdriver
12 | import unittest
13 |
14 | import util
15 |
16 | class Test(unittest.TestCase):
17 | """ Demonstration: Get Chrome to generate fullscreen screenshot """
18 |
19 | def setUp(self):
20 | self.driver = webdriver.Chrome()
21 |
22 | def tearDown(self):
23 | self.driver.quit()
24 |
25 | def test_fullpage_screenshot(self):
26 | ''' Generate document-height screenshot '''
27 | url = "https://gist.github.com/leemengtaiwan/e393d881222885f59ef09a14117159c8"
28 | self.driver.get(url)
29 | time.sleep(5)
30 | util.fullpage_screenshot(self.driver, "test.png")
31 |
32 |
33 | if __name__ == "__main__":
34 | unittest.main(argv=[sys.argv[0]])
--------------------------------------------------------------------------------
/web/util.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | import time
4 | import fire
5 | import hashlib
6 | import requests
7 | from PIL import Image
8 |
9 | GIST_BASE_URL = 'https://gist.github.com'
10 | DRIVER_WIDTH, DRIVER_HEIGHT = 1200, 1373
11 |
12 | def generate_hexhash(content):
13 | """Generate string representation of MD5 sum of given data
14 |
15 | Parameters
16 | ----------
17 | content : dict
18 | A python dictionary representing gist's content
19 |
20 | Returns
21 | -------
22 | hexhash : str. e.g. "c0e14a771bac3b4944318b430efe2884"
23 | """
24 |
25 | data = bytearray(str(content))
26 | md5 = hashlib.md5()
27 | md5.update(data)
28 | hexhash = md5.hexdigest()
29 | return hexhash
30 |
31 |
32 | def get_gist_hash(github_user, gist_name):
33 | """Acquire the raw content of the given `gist_name` and return string repr of the MD5 sum.
34 |
35 | Parameters
36 | ----------
37 | github_user : str
38 | String representing valid Github account. e.g. "leemengtaiwan"
39 |
40 | gist_name : str
41 | Valid gist identifier appear in url
42 |
43 | Returns
44 | -------
45 | hash: str
46 |
47 | """
48 | # TODO update example for gist_name
49 |
50 | gist_raw_url = '/'.join((GIST_BASE_URL, github_user, gist_name, 'raw'))
51 | res = requests.request(
52 | method='GET',
53 | url=gist_raw_url
54 | )
55 | assert res.status_code == requests.codes.ok, "Problem occurred when requesting raw gist."
56 | try:
57 | data = res.json()
58 | except ValueError:
59 | data = res.content
60 |
61 | return generate_hexhash(data)
62 |
63 |
64 | def fullpage_screenshot(driver, file):
65 | """Take multiple screenshots of already-opened webpage and save the concatenated image
66 |
67 | Parameters
68 | ----------
69 | driver : selenium.webdriver
70 | The current active web driver staying in the page to take screenshots
71 | file : str
72 | The file path to save concatenated image
73 |
74 | Returns
75 | -------
76 | bool
77 |
78 | Notes
79 | -----
80 | Generate Fullpage Screenshot in Chrome
81 | http://seleniumpythonqa.blogspot.jp/2015/08/generate-full-page-screenshot-in-chrome.html
82 |
83 | """
84 | image_id = re.findall(r'/([0-9a-zA-Z]+).', file)[0]
85 |
86 |
87 | print("Starting chrome full page screenshot workaround ...")
88 | total_width = driver.execute_script("return document.body.offsetWidth")
89 | total_height = driver.execute_script("return document.body.parentNode.scrollHeight")
90 | viewport_width = driver.execute_script("return document.body.clientWidth")
91 | viewport_height = driver.execute_script("return window.innerHeight")
92 | print("Total: ({0}, {1}), Viewport: ({2},{3})".format(total_width, total_height,viewport_width,viewport_height))
93 | rectangles = []
94 |
95 | i = 0
96 | while i < total_height:
97 | ii = 0
98 | top_height = i + viewport_height
99 |
100 | if top_height > total_height:
101 | top_height = total_height
102 |
103 | while ii < total_width:
104 | top_width = ii + viewport_width
105 |
106 | if top_width > total_width:
107 | top_width = total_width
108 |
109 | print("Appending rectangle ({0},{1},{2},{3})".format(ii, i, top_width, top_height))
110 | rectangles.append((ii, i, top_width,top_height))
111 |
112 | ii = ii + viewport_width
113 |
114 | i = i + viewport_height
115 |
116 | previous = None
117 | part = 0
118 | screenshots = [] # list of dict with 'file_name' and 'offset' fields
119 | total_width, total_height = 0, 0
120 |
121 | for rectangle in rectangles:
122 | if not previous is None:
123 | driver.execute_script("window.scrollTo({0}, {1})".format(rectangle[0], rectangle[1]))
124 | print("Scrolled To ({0},{1})".format(rectangle[0], rectangle[1]))
125 | time.sleep(.2)
126 |
127 | file_name = "{0}_part_{1}.png".format(image_id, part)
128 | print("Capturing {0} ...".format(file_name))
129 |
130 | driver.get_screenshot_as_file(file_name)
131 | screenshot = Image.open(file_name)
132 | img_width, img_height = screenshot.size
133 |
134 |
135 | screenshots.append({
136 | 'file_name': file_name, 'offset': (0, img_height * part)
137 | })
138 | total_width = img_width
139 | total_height += img_height
140 |
141 | del screenshot
142 | part = part + 1
143 | previous = rectangle
144 |
145 | # concatenate all partial images
146 | print(total_width, total_height)
147 | stitched_image = Image.new('RGB', (total_width, total_height))
148 |
149 | for screenshot in screenshots:
150 | file_name, offset = screenshot['file_name'], screenshot['offset']
151 | print("Adding to stitched image with offset ({0}, {1})".format(offset[0], offset[1]))
152 | image = Image.open(file_name)
153 | stitched_image.paste(image, offset)
154 | os.remove(file_name)
155 |
156 | stitched_image.save(file)
157 | print("Finishing chrome full page screenshot workaround...")
158 | return True
159 |
160 |
161 | def create_chrome_driver(mode="headless", width=DRIVER_WIDTH, height=DRIVER_HEIGHT):
162 | """Create a headless/visible Chrome driver.
163 |
164 | Parameters
165 | ----------
166 | mode : str, optional
167 | "headless" for creating a headless Chrome driver.
168 | Otherwise the driver will be visible.
169 |
170 | width : int, optional
171 | Width of the web driver window
172 |
173 | height : int, optional
174 | Height of the web driver window
175 |
176 | Returns
177 | -------
178 | driver
179 | """
180 | from selenium import webdriver
181 | options = webdriver.ChromeOptions()
182 | if mode == 'headless':
183 | options.add_argument("headless")
184 | else:
185 | pass
186 | driver = webdriver.Chrome(chrome_options=options)
187 | driver.get('https://github.com/')
188 | driver.set_window_size(width, height)
189 | return driver
190 |
191 |
192 | if __name__ == '__main__':
193 | fire.Fire()
--------------------------------------------------------------------------------