├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── assets ├── cover.png ├── guide_1.png ├── guide_2.png ├── guide_3.png ├── guide_4.png ├── guide_5.png ├── guide_6.png ├── guide_7.png ├── guide_8.png ├── light.png └── maxresdefault.jpg ├── dist └── worker.js ├── package-lock.json ├── package.json ├── pages ├── error.html └── home.html ├── src ├── helpers │ └── actions.js ├── index.js └── lib │ ├── logger.js │ ├── notify.js │ └── notion.js ├── webpack.config.js └── wrangler.toml /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Cloudflare Workers 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | repository_dispatch: 8 | 9 | jobs: 10 | build-and-deploy: 11 | runs-on: ubuntu-latest 12 | name: Build & Deploy 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: '12.x' 19 | - run: npm install 20 | - name: Publish 21 | uses: cloudflare/wrangler-action@1.3.0 22 | with: 23 | apiToken: ${{ secrets.CF_API_TOKEN }} 24 | env: 25 | CF_ACCOUNT_ID: ${{secrets.CF_ACCOUNT_ID}} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | bin/ 5 | pkg/ 6 | wasm-pack.log 7 | worker/ 8 | node_modules/ 9 | .cargo-ok 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "all", 5 | "tabWidth": 2, 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 NotionStuff 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion Backups 2 | 3 | Set up automated backups for your Notion workspaces that run on a time-basis and notify you by Slack, Discord, or email, so you never worry about losing your Notion data again. 4 | 5 | ![Cover](./assets/light.png) 6 | 7 | ## Warning 8 | ⚠️ This tool uses the Notion unofficial API, which means it could break at any time. I'll try my best to keep it up to date, but you should be aware of this. 9 | 10 | ## Pre-requisites 11 | 12 | - A Free [Cloudflare workers](https://dash.cloudflare.com/sign-up) account 13 | - Notion token `token_v2` (See this [guide](https://www.notion.so/Find-Your-Notion-Token-5da17a8df27a4fb290e9e3b5d9ba89c4)) 14 | 15 | ## Getting started 16 | 17 | 1. Log in to your Cloudflare workers dashboard, select the workers tab, and then create a new service. 18 | ![Step 1](./assets/guide_1.png) 19 | 20 | 2. Give your script a name and then click the 'create service' button. 21 | ![Step 2](./assets/guide_2.png) 22 | 23 | 3. Click on the `Quick edit` button and Copy-paste the [Script code](https://raw.githubusercontent.com/notionblog/notion-backups/master/dist/worker.js?) into the editor. 24 | ![Step 3](./assets/guide_3.png) 25 | 26 | 4. Follow this [guide](https://www.notion.so/Find-Your-Notion-Token-5da17a8df27a4fb290e9e3b5d9ba89c4) to find your Notion token v2. 27 | 28 | 5. Navigate to your **Worker > Settings > Variables** and add the following secrets: 29 | 30 | - `TOKEN_V2` paste the value of your Notion token (**required)** 31 | - `SLACK_WEBHOOK` paste your [Slack webhook](https://api.slack.com/messaging/webhooks#posting_with_webhooks) URL (optional to receive notification via Slack) 32 | - `DISCORD_WEBHOOK` paste your [Discord webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) URL (optional to receive notification via Discord) 33 | ![Step 4](./assets/guide_4.png) 34 | 35 | 6. Returning to your worker editor page, you should now see the following page. 36 | ![Step 5](./assets/guide_5.png) 37 | 7. To manually test the script, add a new variable with the name `MODE` and the value `test` to your environment variables. 38 | ![Step 6](./assets/guide_6.png) 39 | 8. If you click the trigger export button again, you should receive a success message, and it will begin exporting your workspace. If you configured your Discord or Slack webhook url, you should receive a message in a few minutes, and you will also receive an email from Notion. 40 | ![Step 7](./assets/guide_7.png) 41 | 9. in order for the script to execute on a time-based, you must create a cron job task for it, 42 | 43 | Navigate to **Worker > Triggers > Cron Triggers** and create a new cron trigger 44 | 45 | Examples: 46 | 47 | `0 0 * * *` will cause the script to execute once everyday. 48 | 49 | `0 0 */10 * *` will cause the script to execute once every ten days 50 | 51 | `0 0 1 * *` will cause the script to run on a monthly basis. 52 | ![Step 8](./assets/guide_8.png) 53 | 54 | ## Change export format to `Markdown` 55 | 56 | To change the backup's export format from html to markdown or PDF (enterprise plan), create a new environment variable called `EXPORT_TYPE` and set its value to `html` or `markdown`. 57 | 58 | ## Guides 59 | 60 | - [Find your Notion Token](https://www.notion.so/Find-Your-Notion-Token-5da17a8df27a4fb290e9e3b5d9ba89c4) 61 | - [Set up a Slack webhook](https://api.slack.com/messaging/webhooks#posting_with_webhooks) 62 | - [Set up a Discord webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) 63 | 64 | ## Running project locally 65 | 66 | **Requirements** 67 | 68 | - Linux or WSL 69 | - Node 70 | 71 | ### Steps to get server up and running 72 | 73 | **Install wrangler** 74 | 75 | ``` 76 | npm i -g wrangler 77 | ``` 78 | 79 | **Login With Wrangler to Cloudflare** 80 | 81 | ``` 82 | wrangler login 83 | ``` 84 | 85 | **Install packages** 86 | 87 | ``` 88 | npm install 89 | ``` 90 | 91 | **Run** 92 | 93 | ``` 94 | wrangler dev 95 | ``` 96 | 97 | # Support Me 98 | 99 | Buy Me a Coffee at ko-fi.com 100 | -------------------------------------------------------------------------------- /assets/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/cover.png -------------------------------------------------------------------------------- /assets/guide_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/guide_1.png -------------------------------------------------------------------------------- /assets/guide_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/guide_2.png -------------------------------------------------------------------------------- /assets/guide_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/guide_3.png -------------------------------------------------------------------------------- /assets/guide_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/guide_4.png -------------------------------------------------------------------------------- /assets/guide_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/guide_5.png -------------------------------------------------------------------------------- /assets/guide_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/guide_6.png -------------------------------------------------------------------------------- /assets/guide_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/guide_7.png -------------------------------------------------------------------------------- /assets/guide_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/guide_8.png -------------------------------------------------------------------------------- /assets/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/light.png -------------------------------------------------------------------------------- /assets/maxresdefault.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notionblog/notion-backups/cee6a68bf462926ef21685c11b9287faea9418f3/assets/maxresdefault.jpg -------------------------------------------------------------------------------- /dist/worker.js: -------------------------------------------------------------------------------- 1 | !function(e){var t={};function n(o){if(t[o])return t[o].exports;var a=t[o]={i:o,l:!1,exports:{}};return e[o].call(a.exports,a,a.exports,n),a.l=!0,a.exports}n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)n.d(o,a,function(t){return e[t]}.bind(null,a));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=2)}([function(e,t){e.exports=' Notion Backups
✅ It\'s all right!

To manually test the script, click this button.

in order for the script to execute on a time-based, you must create
a cron job task for it, Navigate to Worker > Triggers > Cron Triggers
and add a new cron trigger

Examples:
111 | 112 | 113 | -------------------------------------------------------------------------------- /src/helpers/actions.js: -------------------------------------------------------------------------------- 1 | import { exportWorkspaces } from '../lib/notion' 2 | import { notify } from '../lib/notify' 3 | import { logger } from '../lib/logger' 4 | 5 | /* 6 | * Export workspaces & send notifications 7 | * 8 | */ 9 | export const triggerExport = async () => { 10 | try { 11 | // check if token is set 12 | if (typeof TOKEN_V2 === 'undefined') return 13 | console.log('Start', TOKEN_V2) 14 | // Notify the user that process has started 15 | const msg = `📡 *log*: starting the backup process\n` 16 | await logger(msg) 17 | // Start the export 18 | const tasks = await exportWorkspaces() 19 | // Notify the user once the process has been finished 20 | await notify(tasks) 21 | } catch (err) { 22 | console.error(err) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { triggerExport } from './helpers/actions' 2 | import home from '../pages/home.html' 3 | import error from '../pages/error.html' 4 | 5 | addEventListener('fetch', event => { 6 | event.respondWith(handleRequest(event)) 7 | }) 8 | addEventListener('scheduled', event => { 9 | event.waitUntil(triggerExport()) 10 | }) 11 | 12 | async function handleRequest(event) { 13 | const { pathname } = new URL(event.request.url) 14 | 15 | if (typeof TOKEN_V2 === 'undefined') { 16 | return new Response(error, { 17 | headers: { 18 | 'content-type': 'text/html', 19 | }, 20 | }) 21 | } 22 | 23 | if (pathname === '/test') { 24 | if (typeof MODE !== 'undefined' && MODE == 'test') { 25 | event.waitUntil(triggerExport()) 26 | return new Response('OK', { status: 200 }) 27 | } else { 28 | return new Response( 29 | 'Failed to trigger the script, please enable test mode', 30 | { status: 400 }, 31 | ) 32 | } 33 | } 34 | 35 | return new Response(home, { 36 | headers: { 37 | 'content-type': 'text/html', 38 | }, 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/logger.js: -------------------------------------------------------------------------------- 1 | const _slack = async msg => { 2 | const content = { 3 | blocks: [ 4 | { 5 | type: 'section', 6 | text: { 7 | type: 'mrkdwn', 8 | text: msg, 9 | }, 10 | }, 11 | ], 12 | } 13 | await fetch(SLACK_WEBHOOK, { 14 | method: 'POST', 15 | body: JSON.stringify(content), 16 | headers: { 17 | 'Content-Type': 'application/json', 18 | }, 19 | }) 20 | } 21 | 22 | const _discord = async msg => { 23 | const content = { 24 | username: 'Logs', 25 | content: msg, 26 | } 27 | 28 | await fetch(DISCORD_WEBHOOK, { 29 | method: 'POST', 30 | body: JSON.stringify(content), 31 | headers: { 32 | 'Content-Type': 'application/json', 33 | }, 34 | }) 35 | } 36 | 37 | export const logger = async msg => { 38 | try { 39 | if (typeof SLACK_WEBHOOK !== 'undefined') await _slack(msg) 40 | if (typeof DISCORD_WEBHOOK !== 'undefined') await _discord(msg) 41 | } catch (err) { 42 | console.error(err) 43 | throw new Error('failed to send notification') 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/notify.js: -------------------------------------------------------------------------------- 1 | const _exportTime = () => { 2 | const today = new Date() 3 | return `${today.getFullYear()}-${today.getMonth() + 4 | 1}-${today.getDate()} -- ${today.getHours()}:${today.getMinutes()}` 5 | } 6 | 7 | /* 8 | * Convert an export task to a slack message block 9 | * 10 | * @param {task} export task 11 | * @return {object} contains slack block 12 | */ 13 | const _slackBlock = task => ({ 14 | type: 'section', 15 | text: { 16 | type: 'mrkdwn', 17 | text: 18 | task.status === 'fulfilled' 19 | ? task.value.name + ' workspace' + ' `✅ Success`' 20 | : task.reason + ' `❌ Failed`', 21 | }, 22 | accessory: { 23 | type: 'button', 24 | text: { 25 | type: 'plain_text', 26 | text: 'Download', 27 | emoji: true, 28 | }, 29 | value: 'download', 30 | url: 31 | task.status === 'fulfilled' 32 | ? task.value.url 33 | : 'https://youtu.be/dQw4w9WgXcQ', 34 | action_id: 'button-action', 35 | }, 36 | }) 37 | /* 38 | * Send Notification via slack 39 | * 40 | * @param {Array} array of export tasks 41 | */ 42 | const _slack = async tasks => { 43 | const content = { 44 | blocks: [ 45 | { 46 | type: 'header', 47 | text: { 48 | type: 'plain_text', 49 | text: `💾 Backup: ${_exportTime()}`, 50 | emoji: true, 51 | }, 52 | }, 53 | { 54 | type: 'divider', 55 | }, 56 | ...tasks.map(task => _slackBlock(task)), 57 | { 58 | type: 'divider', 59 | }, 60 | ], 61 | } 62 | await fetch(SLACK_WEBHOOK, { 63 | method: 'POST', 64 | body: JSON.stringify(content), 65 | headers: { 66 | 'Content-Type': 'application/json', 67 | }, 68 | }) 69 | } 70 | 71 | /* 72 | * Send Notification via Discord 73 | * 74 | * @param {Array} array of export tasks 75 | */ 76 | const _discord = async tasks => { 77 | const content = { 78 | username: 'Notion Backups', 79 | content: `> 💾 **Backup: ${_exportTime()}**`, 80 | embeds: [ 81 | ...tasks.map(task => ({ 82 | color: 5763719, 83 | author: { 84 | name: 85 | task.status === 'fulfilled' 86 | ? task.value.name + ' workspace' 87 | : '❌ Failed: ' + task.reason, 88 | }, 89 | title: 'Download', 90 | url: 91 | task.status === 'fulfilled' 92 | ? task.value.url 93 | : 'https://youtu.be/dQw4w9WgXcQ', 94 | })), 95 | ], 96 | } 97 | 98 | await fetch(DISCORD_WEBHOOK, { 99 | method: 'POST', 100 | body: JSON.stringify(content), 101 | headers: { 102 | 'Content-Type': 'application/json', 103 | }, 104 | }) 105 | } 106 | 107 | /* 108 | * Send Notifications to the users selected channels 109 | * 110 | * @param {Array} array of export tasks 111 | * @param ${string} channel name 112 | */ 113 | export const notify = async tasks => { 114 | try { 115 | if (typeof SLACK_WEBHOOK !== 'undefined') await _slack(tasks) 116 | if (typeof DISCORD_WEBHOOK !== 'undefined') await _discord(tasks) 117 | } catch (err) { 118 | console.error(err) 119 | throw new Error('failed to send notification') 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/lib/notion.js: -------------------------------------------------------------------------------- 1 | const secret = globalThis.TOKEN_V2 2 | 3 | const _BASEURL = 'https://www.notion.so/api/v3' 4 | const _HEADERS = { 5 | Cookie: `token_v2=${typeof secret !== undefined ? secret : ''};`, 6 | 'Content-Type': 'application/json', 7 | } 8 | 9 | /** 10 | * Return list of available user workspaces 11 | * 12 | * @return {Array} arrray of spaces with name and id 13 | */ 14 | 15 | const _getSpaces = async () => { 16 | const res = await fetch(`${_BASEURL}/getSpaces`, { 17 | method: 'POST', 18 | headers: _HEADERS, 19 | }) 20 | const data = await res.json() 21 | const userSpaces = data[Object.keys(data)[0]].space 22 | const userSpacesIds = Object.keys(userSpaces) 23 | return userSpacesIds.map(spaceId => ({ 24 | id: userSpaces[spaceId].value.id, 25 | name: userSpaces[spaceId].value.name, 26 | })) 27 | } 28 | 29 | /** 30 | * Setup an export task for a space 31 | * 32 | * @param {string} spaceid uuid of the space 33 | * @return {string} task id 34 | */ 35 | 36 | const _setupExportTask = async spaceId => { 37 | const task = { 38 | task: { 39 | eventName: 'exportSpace', 40 | request: { 41 | spaceId, 42 | exportOptions: { 43 | exportType: typeof EXPORT_TYPE !== 'undefined' ? EXPORT_TYPE : 'html', 44 | timeZone: typeof TIMEZONE !== 'undefined' ? TIMEZONE : 'Etc/UTC', 45 | locale: typeof LOCALE !== 'undefined' ? LOCALE : 'en', 46 | }, 47 | }, 48 | }, 49 | } 50 | const res = await fetch(`${_BASEURL}/enqueueTask`, { 51 | method: 'POST', 52 | body: JSON.stringify(task), 53 | headers: _HEADERS, 54 | }) 55 | 56 | const { taskId } = await res.json() 57 | return taskId 58 | } 59 | 60 | /** 61 | * Check the export task status 62 | * 63 | * @param {string} taskId id of the task 64 | * @return {object} status contains informations about the task progress 65 | */ 66 | 67 | const _checkExportTask = async taskId => { 68 | const res = await fetch(`${_BASEURL}/getTasks`, { 69 | method: 'POST', 70 | body: JSON.stringify({ taskIds: [taskId] }), 71 | headers: _HEADERS, 72 | }) 73 | const data = await res.json() 74 | 75 | return data.results[0].status 76 | } 77 | 78 | /** 79 | * Check the task status every 5s, then resolve the export data after the process is finished. 80 | * 81 | * @param {object} space object contains name and id 82 | * @return {string} export url 83 | * @throws {string} error message 84 | */ 85 | const _exportSpace = space => { 86 | return new Promise(async (resolve, reject) => { 87 | try { 88 | const taskId = await _setupExportTask(space.id) 89 | 90 | const check = setInterval(async () => { 91 | const taskStatus = await _checkExportTask(taskId) 92 | if (!taskStatus) { 93 | clearInterval(check) 94 | reject(new Error(`Failed to export ${space.name} workspace`)) 95 | } else if (taskStatus.type === 'complete') { 96 | clearInterval(check) 97 | const data = { 98 | ...space, 99 | url: taskStatus.exportURL, 100 | } 101 | resolve(data) 102 | } 103 | }, 5000) 104 | } catch (err) { 105 | console.error(err) 106 | reject(new Error('Something went wrong')) 107 | } 108 | }) 109 | } 110 | 111 | /** 112 | * Export all user workspaces 113 | * 114 | * @return {Array} arrray of the status and values of all spaces export 115 | */ 116 | 117 | export const exportWorkspaces = async () => { 118 | let spaces = await _getSpaces() 119 | const tasks = spaces.map(space => _exportSpace(space)) 120 | return Promise.allSettled(tasks) 121 | } 122 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | target: 'webworker', 3 | context: __dirname, 4 | entry: './src/index.js', 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.html$/i, 9 | loader: 'html-loader', 10 | }, 11 | ], 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "notion-backups" 2 | type = "webpack" 3 | webpack_config = "webpack.config.js" 4 | account_id = "" 5 | workers_dev = true 6 | route = "" 7 | zone_id = "" 8 | compatibility_date = "2022-04-29" 9 | [triggers] 10 | crons = ["0 0 * * *"] 11 | #[secrets] 12 | #TOKEN_V2 13 | #SLACK_WEBHOOK 14 | #DISCORD_WEBHOOK 15 | #EXPORTTYPE 16 | #LOCALE 17 | #TIMEZONE 18 | #MODE 19 | --------------------------------------------------------------------------------