├── .github └── workflows │ └── pythonapp.yml ├── .gitignore ├── README.md ├── backup └── .gitkeep ├── notion ├── __init__.py ├── __main__.py └── export_notion.py └── requirements.txt /.github/workflows/pythonapp.yml: -------------------------------------------------------------------------------- 1 | name: Backup notion 2 | 3 | on: 4 | push: 5 | schedule: 6 | - cron: '5 4 * * *' 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up Python 3.7 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.7 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r requirements.txt 23 | - name: Download backup and push to GDrive 24 | run: python notion 25 | env: 26 | NOTION_LOCALE: en 27 | NOTION_TIMEZONE: 'Europe/London' 28 | NOTION_SPACE_ID: ${{ secrets.NOTION_SPACE_ID }} 29 | NOTION_EMAIL: ${{ secrets.NOTION_EMAIL }} 30 | NOTION_PASSWORD: ${{ secrets.NOTION_PASSWORD }} 31 | GDRIVE_ROOT_FOLDER_ID: ${{ secrets.GDRIVE_ROOT_FOLDER_ID }} 32 | GDRIVE_SERVICE_ACCOUNT: ${{ secrets.GDRIVE_SERVICE_ACCOUNT }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | venv 3 | export.zip 4 | __pycache__ 5 | .env 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion Backup 2 | 3 | This repo makes a backup of OpenOwnership's Notion workspace. 4 | 5 | The code runs via a Github Action, which is scheduled to run at 4:05am every 6 | day, as well as whenever code is pushed to the repository. 7 | 8 | ## Running the code locally 9 | 10 | The code to run the backup lives in /notion/export_notion.py. To run it: 11 | 12 | 1. Install requirements 13 | 14 | ```shell 15 | git clone git@github.com:openownership/notion-backup.git 16 | cd notion-backup 17 | python3 -m venv venv 18 | source venv/bin/activate 19 | pip install -r requirements.txt 20 | touch .env 21 | ``` 22 | 23 | 2. Set your credentials in the `.env` file: 24 | 25 | ```shell 26 | NOTION_SPACE_ID=1234-56789-abcdef 27 | NOTION_EMAIL=notion@example.com 28 | NOTION_PASSWORD=password 29 | GDRIVE_ROOT_FOLDER_ID= 30 | GDRIVE_SERVICE_ACCOUNT= 31 | ``` 32 | 33 | 3. Run the python module: `python notion` 34 | 35 | You can find the space id by logging into Notion as the tech+notion user and then 36 | inspecting one of the ajax requests that notion's front end makes in the 37 | chrome dev console. It's often found in the body of responses from Notion. 38 | 39 | Note that this assumes you have a email/password user account, not one through 40 | Google SSO. 41 | 42 | ## Github Action config 43 | 44 | The Github Action is configured via secrets set up in [the repo settings](https://github.com/openownership/notion-backup/settings/secrets). 45 | These are then set as env vars for the python script to use. 46 | -------------------------------------------------------------------------------- /backup/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openownership/notion-backup/b4e575d38915b634f4aeda8c038c2c94e34357eb/backup/.gitkeep -------------------------------------------------------------------------------- /notion/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openownership/notion-backup/b4e575d38915b634f4aeda8c038c2c94e34357eb/notion/__init__.py -------------------------------------------------------------------------------- /notion/__main__.py: -------------------------------------------------------------------------------- 1 | from export_notion import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /notion/export_notion.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import time 4 | import json 5 | import datetime 6 | 7 | import requests 8 | from notion.client import NotionClient 9 | from dotenv import load_dotenv 10 | from google.oauth2 import service_account 11 | from google.auth.transport.requests import AuthorizedSession 12 | 13 | 14 | load_dotenv() 15 | 16 | 17 | def notionToken(): 18 | loginData = { 19 | 'email': os.getenv('NOTION_EMAIL'), 20 | 'password': os.getenv('NOTION_PASSWORD') 21 | } 22 | headers = { 23 | # Notion obviously check this as some kind of (bad) test of CSRF 24 | 'host': 'www.notion.so' 25 | } 26 | response = requests.post( 27 | 'https://notion.so/api/v3/loginWithEmail', 28 | json=loginData, 29 | headers=headers 30 | ) 31 | response.raise_for_status() 32 | return response.cookies['token_v2'] 33 | 34 | 35 | def exportTask(): 36 | return { 37 | 'task': { 38 | 'eventName': "exportSpace", 39 | 'request': { 40 | 'spaceId': os.getenv('NOTION_SPACE_ID'), 41 | 'exportOptions': { 42 | 'exportType': 'markdown', 43 | 'timeZone': os.getenv('NOTION_TIMEZONE'), 44 | 'locale': os.getenv('NOTION_LOCALE') 45 | } 46 | } 47 | } 48 | } 49 | 50 | 51 | def exportUrl(client, taskId): 52 | url = False 53 | print('Polling for export task: {}'.format(taskId)) 54 | while True: 55 | response = client.post('getTasks', {'taskIds': [taskId]}) 56 | tasks = response.json().get('results') 57 | task = next(t for t in tasks if t['id'] == taskId) 58 | if task['state'] == 'success': 59 | url = task['status']['exportURL'] 60 | print() 61 | print(url) 62 | break 63 | else: 64 | print('.', end="", flush=True) 65 | time.sleep(10) 66 | return url 67 | 68 | 69 | def downloadFile(url, filename): 70 | with requests.get(url, stream=True) as r: 71 | with open('backup/{}'.format(filename), 'wb') as f: 72 | shutil.copyfileobj(r.raw, f) 73 | 74 | 75 | def createFolder(session): 76 | metadata = { 77 | 'name': datetime.datetime.utcnow().isoformat(), 78 | 'mimeType': 'application/vnd.google-apps.folder', 79 | 'parents': [os.getenv('GDRIVE_ROOT_FOLDER_ID')] 80 | } 81 | response = session.post( 82 | 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true', 83 | json=metadata 84 | ) 85 | response.raise_for_status() 86 | return response.json()['id'] 87 | 88 | 89 | def upload(name, session, folderId, mimeType): 90 | file_metadata = { 91 | 'name': name, 92 | 'mimeType': mimeType, 93 | 'parents': [folderId] 94 | } 95 | start_response = session.post( 96 | 'https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&supportsAllDrives=true', 97 | json=file_metadata 98 | ) 99 | start_response.raise_for_status() 100 | resumable_uri = start_response.headers.get('Location') 101 | file = open('backup/{}'.format(name), 'rb') 102 | upload_response = session.put(resumable_uri, data=file) 103 | upload_response.raise_for_status() 104 | 105 | 106 | def main(): 107 | token = notionToken() 108 | client = NotionClient(token_v2=token) 109 | taskId = client.post('enqueueTask', exportTask()).json().get('taskId') 110 | url = exportUrl(client, taskId) 111 | downloadFile(url, 'export.zip') 112 | 113 | service_account_info = json.loads(os.getenv('GDRIVE_SERVICE_ACCOUNT')) 114 | google_credentials = service_account.Credentials.from_service_account_info( 115 | service_account_info, 116 | scopes=['https://www.googleapis.com/auth/drive'] 117 | ) 118 | authed_session = AuthorizedSession(google_credentials) 119 | folderId = createFolder(authed_session) 120 | upload('export.zip', authed_session, folderId, 'application/zip') 121 | 122 | 123 | if __name__ == "__main__": 124 | main() 125 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.8.2 2 | bs4==0.0.1 3 | cached-property==1.5.1 4 | cachetools==4.0.0 5 | certifi==2019.11.28 6 | chardet==3.0.4 7 | commonmark==0.9.1 8 | dictdiffer==0.8.1 9 | entrypoints==0.3 10 | flake8==3.7.9 11 | google-auth==1.13.1 12 | idna==2.9 13 | mccabe==0.6.1 14 | notion==0.0.25 15 | pyasn1==0.4.8 16 | pyasn1-modules==0.2.8 17 | pycodestyle==2.5.0 18 | pyflakes==2.1.1 19 | python-dotenv==0.12.0 20 | python-slugify==4.0.0 21 | pytz==2019.3 22 | requests==2.23.0 23 | rsa==4.0 24 | six==1.14.0 25 | soupsieve==2.0 26 | text-unidecode==1.3 27 | tzlocal==2.0.0 28 | urllib3==1.25.8 29 | --------------------------------------------------------------------------------